Added additional mcp capabilities for automation support

This commit is contained in:
Tony West committed 2026-08-11 17:42:43 -04:00
1 parent fa7df9244a
commit 04833cad99
39 files changed
+2254 -244

No files matched your search

+30
View File
@@ -4,12 +4,14 @@ import (
"context"
"fmt"
"log"
"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/mcp"
)
@@ -31,18 +33,27 @@ func (s *APIServer) SetAutomation(store *automation.Store) {
}
s.autoStore = store
s.capAudit = capability.NewAuditLog(capability.DefaultAuditSize)
s.capContexts = httptools.NewContexts()
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
},
// 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
@@ -59,9 +70,28 @@ func (s *APIServer) SetAutomation(store *automation.Store) {
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,
Broadcast: s.hub.Broadcast(),
}, s.capAudit)
s.mcpListener = mcp.NewListener()
if s.cfg.AutomationPrivileged {
var ids []string
for _, c := range s.capRegistry.All() {
if c.Privileged {
ids = append(ids, c.ID)
}
}
log.Printf("[automation] --automation-privileged: %s are grantable to an automation token. "+
"No profile includes them; grant each by hand.", strings.Join(ids, " "))
}
}
// automationEnabled reports whether the automation surface is available.
+40 -22
View File
@@ -47,10 +47,13 @@ type capabilityView struct {
// 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"`
InputSchema json.RawMessage `json:"inputSchema"`
MaxOutputBytes int `json:"maxOutputBytes"`
ToolName string `json:"toolName"`
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 {
@@ -97,14 +100,16 @@ func (s *APIServer) tokenViews() []tokenView {
}
type createTokenReq struct {
Name string `json:"name"`
Grants []string `json:"grants"`
RequireScope *bool `json:"requireScope"`
HostAllow []string `json:"hostAllow"`
RateLimitPerMin int `json:"rateLimitPerMin"`
MaxConcurrent int `json:"maxConcurrent"`
MaxOutputBytes int `json:"maxOutputBytes"`
ExpiresInDays int `json:"expiresInDays"`
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) {
@@ -135,6 +140,7 @@ func (s *APIServer) handleCreateAutomationToken(w http.ResponseWriter, r *http.R
Grants: normalizeGrants(body.Grants),
RequireScope: requireScope,
HostAllow: normalizeHostAllow(body.HostAllow),
AllowCredentials: body.AllowCredentials,
RateLimitPerMin: body.RateLimitPerMin,
MaxConcurrent: body.MaxConcurrent,
MaxOutputBytes: body.MaxOutputBytes,
@@ -151,13 +157,15 @@ func (s *APIServer) handleCreateAutomationToken(w http.ResponseWriter, r *http.R
}
type updateTokenReq struct {
Name *string `json:"name"`
Grants *[]string `json:"grants"`
RequireScope *bool `json:"requireScope"`
HostAllow *[]string `json:"hostAllow"`
RateLimitPerMin *int `json:"rateLimitPerMin"`
MaxConcurrent *int `json:"maxConcurrent"`
MaxOutputBytes *int `json:"maxOutputBytes"`
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) {
@@ -172,7 +180,8 @@ func (s *APIServer) handleUpdateAutomationToken(w http.ResponseWriter, r *http.R
p := automation.UpdateParams{
Name: body.Name, RequireScope: body.RequireScope,
RateLimitPerMin: body.RateLimitPerMin, MaxConcurrent: body.MaxConcurrent,
AllowCredentials: body.AllowCredentials,
RateLimitPerMin: body.RateLimitPerMin, MaxConcurrent: body.MaxConcurrent,
MaxOutputBytes: body.MaxOutputBytes,
}
if body.Grants != nil {
@@ -215,11 +224,14 @@ func (s *APIServer) handleRotateAutomationToken(w http.ResponseWriter, r *http.R
if !s.requireAutomation(w) {
return
}
tok, secret, err := s.autoStore.Rotate(r.PathValue("id"))
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})
}
@@ -235,6 +247,9 @@ func (s *APIServer) handleSetAutomationTokenEnabled(w http.ResponseWriter, r *ht
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)
@@ -271,8 +286,10 @@ func (s *APIServer) handleRevokeAutomationToken(w http.ResponseWriter, r *http.R
s.writeTokenErr(w, err)
return
}
// Drop the limiter state so a future token cannot inherit a drained bucket.
// 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"})
}
@@ -287,6 +304,7 @@ func (s *APIServer) handleListCapabilities(w http.ResponseWriter, r *http.Reques
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, ".", "_"),
+19 -13
View File
@@ -22,19 +22,19 @@ import (
// --- User Config ---
type userConfigFile struct {
Version int `json:"version"`
SOCKSHost string `json:"socksHost"`
SOCKSPort int `json:"socksPort"`
SOCKSUsername string `json:"socksUsername"`
SOCKSPassword string `json:"socksPassword"`
SOCKSDNS bool `json:"socksDns"`
HTTP2Enabled bool `json:"http2Enabled"`
KeepAliveEnabled bool `json:"keepAliveEnabled"`
InterceptTimeout int `json:"interceptTimeout"`
MaxRequests int `json:"maxRequests"`
DisableUpdateChecks bool `json:"disableUpdateChecks"`
Theme string `json:"theme"`
HiddenTabs []string `json:"hiddenTabs,omitempty"`
Version int `json:"version"`
SOCKSHost string `json:"socksHost"`
SOCKSPort int `json:"socksPort"`
SOCKSUsername string `json:"socksUsername"`
SOCKSPassword string `json:"socksPassword"`
SOCKSDNS bool `json:"socksDns"`
HTTP2Enabled bool `json:"http2Enabled"`
KeepAliveEnabled bool `json:"keepAliveEnabled"`
InterceptTimeout int `json:"interceptTimeout"`
MaxRequests int `json:"maxRequests"`
DisableUpdateChecks bool `json:"disableUpdateChecks"`
Theme string `json:"theme"`
HiddenTabs []string `json:"hiddenTabs,omitempty"`
// DashboardLayout is the operator's dashboard widget layout, opaque to Go
// (same treatment as PluginStates): adding a widget or a preset is a
// frontend-only change. Absent on v3 and earlier files, in which case the
@@ -590,6 +590,8 @@ func (s *APIServer) resetLiveProjectState() {
// Clear zeroes the store's sequence counter; resetDetectLiveState moves the
// detection cursor back to zero with it.
s.resetDetectLiveState()
// An automation session belongs to the engagement it authenticated against.
s.capContexts.ResetAll()
s.mu.Lock()
s.highlights = make(map[string]string)
@@ -660,6 +662,10 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
decodedPluginStates := decodePluginStates(cfg.PluginStates)
// An automation session belongs to the engagement it authenticated against, so
// it does not survive into a different project.
s.capContexts.ResetAll()
// Apply team server settings.
s.mu.Lock()
nickname := cfg.TeamNickname
+2
View File
@@ -24,6 +24,7 @@ import (
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/fuzzer"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/mcp"
"github.com/BishopFox/joro/internal/mythic"
"github.com/BishopFox/joro/internal/notes"
@@ -115,6 +116,7 @@ type APIServer struct {
capRegistry *capability.Registry
capAudit *capability.AuditLog
autoStore *automation.Store
capContexts *httptools.Contexts
mcpListener *mcp.Listener
automationMu sync.Mutex // serializes MCP start/stop from HTTP handlers
+6
View File
@@ -214,6 +214,7 @@ type CreateParams struct {
Grants []string
RequireScope bool
HostAllow []string
AllowCredentials bool
RateLimitPerMin int
MaxConcurrent int
MaxOutputBytes int
@@ -243,6 +244,7 @@ func (s *Store) Create(p CreateParams) (Token, string, error) {
Grants: slices.Clone(p.Grants),
RequireScope: p.RequireScope,
HostAllow: slices.Clone(p.HostAllow),
AllowCredentials: p.AllowCredentials,
RateLimitPerMin: p.RateLimitPerMin,
MaxConcurrent: p.MaxConcurrent,
MaxOutputBytes: p.MaxOutputBytes,
@@ -274,6 +276,7 @@ type UpdateParams struct {
Grants *[]string
RequireScope *bool
HostAllow *[]string
AllowCredentials *bool
RateLimitPerMin *int
MaxConcurrent *int
MaxOutputBytes *int
@@ -302,6 +305,9 @@ func (s *Store) Update(id string, p UpdateParams) (Token, error) {
if p.HostAllow != nil {
t.HostAllow = slices.Clone(*p.HostAllow)
}
if p.AllowCredentials != nil {
t.AllowCredentials = *p.AllowCredentials
}
if p.RateLimitPerMin != nil {
t.RateLimitPerMin = *p.RateLimitPerMin
}
+11 -5
View File
@@ -48,6 +48,10 @@ type Token struct {
RequireScope bool `json:"requireScope"`
HostAllow []string `json:"hostAllow,omitempty"`
// AllowCredentials lets tools return the values of Authorization, Cookie and
// similar headers rather than masking them. Off by default.
AllowCredentials bool `json:"allowCredentials"`
RateLimitPerMin int `json:"rateLimitPerMin"`
MaxConcurrent int `json:"maxConcurrent"`
MaxOutputBytes int `json:"maxOutputBytes"`
@@ -113,11 +117,13 @@ func (t *Token) Principal() capability.Principal {
grants[g] = struct{}{}
}
return capability.Principal{
TokenID: t.ID,
TokenName: t.Name,
Grants: grants,
RequireScope: t.RequireScope,
HostAllow: slices.Clone(t.HostAllow),
TokenID: t.ID,
TokenName: t.Name,
Grants: grants,
RequireScope: t.RequireScope,
HostAllow: slices.Clone(t.HostAllow),
AllowCredentials: t.AllowCredentials,
RateLimitPerMin: t.RateLimitPerMin,
MaxConcurrent: t.MaxConcurrent,
MaxOutputBytes: t.MaxOutputBytes,
+7
View File
@@ -43,6 +43,13 @@ type AuditEntry struct {
// after-the-fact review can find every send that bypassed the scope check.
RequireScope bool `json:"requireScope"`
// Credentials records that this call could return unmasked Authorization and
// Cookie values, so a review can find every one that did.
Credentials bool `json:"credentials,omitempty"`
// Privileged records an execution or C2 invocation.
Privileged bool `json:"privileged,omitempty"`
// ArgsDigest identifies repeated calls without storing the arguments.
// Arguments to a send carry credentials, session cookies and payloads;
// retaining them would make the audit log a secondary secret store that
+26 -10
View File
@@ -29,14 +29,20 @@ import (
type Class string
const (
ClassHistory Class = "history"
ClassSitemap Class = "sitemap"
ClassScope Class = "scope"
ClassFindings Class = "findings"
ClassNotes Class = "notes"
ClassHTTP Class = "http"
ClassConfig Class = "config"
ClassDetect Class = "detect"
ClassInstance Class = "instance"
ClassHistory Class = "history"
ClassSitemap Class = "sitemap"
ClassScope Class = "scope"
ClassFindings Class = "findings"
ClassNotes Class = "notes"
ClassHTTP Class = "http"
ClassWebSocket Class = "websocket"
ClassFuzzer Class = "fuzzer"
ClassContext Class = "context"
ClassConfig Class = "config"
ClassDetect Class = "detect"
ClassExec Class = "exec"
ClassC2 Class = "c2"
)
// Classes lists every valid class, in the order the grant picker shows them. The
@@ -44,8 +50,9 @@ const (
// would put config and detect at the top of the picker, leading with the groups an
// operator should consider last.
var Classes = []Class{
ClassHistory, ClassSitemap, ClassScope, ClassFindings, ClassNotes, ClassHTTP,
ClassConfig, ClassDetect,
ClassInstance, ClassHistory, ClassSitemap, ClassScope, ClassFindings, ClassNotes,
ClassHTTP, ClassWebSocket, ClassFuzzer, ClassContext, ClassConfig, ClassDetect,
ClassExec, ClassC2,
}
func validClass(c Class) bool { return slices.Contains(Classes, c) }
@@ -98,6 +105,15 @@ type Capability struct {
Mutating bool
SendsTraffic bool
// Privileged marks a capability that drives command execution or an operator's
// C2. These are registered only when Joro is started with --automation-privileged,
// and even then no profile grants one: the operator must select it by hand.
//
// The flag itself is not the gate — an unregistered capability cannot be granted,
// listed or invoked at all. It exists so validateProfiles can refuse to bundle
// one, the grant picker can mark it, and the audit log can record it.
Privileged bool
// UnrestrictedOnly refuses the capability to any token the operator has
// leashed — one with RequireScope set, or with a non-empty HostAllow.
//
+7
View File
@@ -27,6 +27,13 @@ type Principal struct {
// RequireScope exists to close.
HostAllow []string
// AllowCredentials lets this principal see the values of Authorization, Cookie
// and similar headers. Without it they are masked wherever a capability can
// return raw bytes. It is a policy field rather than a grant because it modifies
// what other capabilities return, and a no-op tool would cost the model context
// on every turn. Defaults to false, so a token that predates it is narrowed.
AllowCredentials bool
// Per-principal limits. Zero means the registry default.
RateLimitPerMin int
MaxConcurrent int
+2
View File
@@ -251,6 +251,7 @@ func (r *Registry) Invoke(ctx context.Context, p Principal, id string, args json
TokenName: p.TokenName,
Capability: id,
RequireScope: p.RequireScope,
Credentials: p.AllowCredentials,
ArgsDigest: digestArgs(args),
ArgsBytes: len(args),
}
@@ -279,6 +280,7 @@ func (r *Registry) Invoke(ctx context.Context, p Principal, id string, args json
if !ok || !p.Can(id) {
return Result{}, errf(CodeForbidden, "unknown or not granted: %s", id)
}
entry.Privileged = capDef.Privileged
// 2. Reserved-prefix re-check. Redundant with Register, one string compare, and
// the thing that still holds if a grant list is hand-edited.
+34
View File
@@ -17,8 +17,12 @@ import (
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/cert"
"github.com/BishopFox/joro/internal/detect"
"github.com/BishopFox/joro/internal/fuzzer"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/mythic"
"github.com/BishopFox/joro/internal/notes"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/sliver"
)
// Deps carries exactly the components capabilities may touch.
@@ -32,8 +36,31 @@ type Deps struct {
Findings *detect.Store
Engine *detect.Engine
Notes *notes.Store
WSStore *proxy.WSStore
CA *cert.CA
ProxyAddr string // Joro's own proxy listener, e.g. "127.0.0.1:8080"
Version string
// ActiveProject names the loaded project. A getter because it changes at
// runtime and this package must not reach into internal/api to read it.
ActiveProject func() string
// Contexts holds one cookie jar per automation principal, so a send can stay
// authenticated across calls.
Contexts *httptools.Contexts
// Fuzzer backs the fuzzer capabilities. Transport is the same dialer the Fuzz
// tab uses, so an agent-started campaign honors SOCKS and HTTP/2 identically —
// and, like the UI's, its traffic does not pass through Joro's own proxy.
Fuzzer *fuzzer.Store
Transport *proxy.TransportConfig
// Privileged enables the execution and C2 capabilities, from
// --automation-privileged. When false they are not registered at all, so they
// cannot be granted, listed or invoked.
Privileged bool
Sliver *sliver.Client
Mythic *mythic.Client
// The proxy's behavioral rule stores, for the config-class capabilities. These
// are what "modify the proxy configuration" means here: Settings itself lives on
@@ -74,6 +101,7 @@ func Build(d Deps, audit *capability.AuditLog) *capability.Registry {
}
r := capability.NewRegistry(scope, audit)
registerInstance(r, d)
registerHistory(r, d)
registerSitemap(r, d)
registerScope(r, d)
@@ -81,9 +109,15 @@ func Build(d Deps, audit *capability.AuditLog) *capability.Registry {
registerFindings(r, d)
registerNotes(r, d)
registerHTTP(r, d)
registerExecContext(r, d)
registerWebSocket(r, d)
registerFuzzer(r, d)
registerWrites(r, d)
registerConfig(r, d)
registerDetect(r, d)
if d.Privileged {
registerPrivileged(r, d)
}
validateProfiles(r)
r.Seal()
+39
View File
@@ -7,7 +7,9 @@ import (
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/BishopFox/joro/internal/capability"
)
@@ -102,6 +104,43 @@ func registerConfig(r *capability.Registry, d Deps) {
return out, nil
}),
})
// Read-only, and it stays that way. Interception is the operator's last look at
// bytes before they reach their browser, and a forward or drop from here has no
// undo. There is deliberately no config.intercept.forward or .drop.
r.MustRegister(capability.Capability{
ID: "config.intercept.list",
Class: capability.ClassConfig,
Title: "List paused intercept items",
Description: "The requests and responses currently paused in the operator's intercept queue: id, kind, " +
"method, URL, status and how long each has been waiting. Raw bytes are not returned. If a send of " +
"yours timed out, look here for it. Only the operator can forward or drop an item.",
InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
ArgsExample: json.RawMessage(`{}`),
MaxOutputBytes: 32 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, _ struct{}) (any, error) {
if d.Intercept == nil {
return nil, fmt.Errorf("intercept is unavailable")
}
items := d.Intercept.List()
if len(items) == 0 {
return "(nothing paused)", nil
}
var b strings.Builder
fmt.Fprintf(&b, "n=%d\n", len(items))
now := time.Now()
for _, it := range items {
status := "-"
if it.Status > 0 {
status = strconv.Itoa(it.Status)
}
fmt.Fprintf(&b, "%s %s %s %s status=%s waiting=%s\n",
it.ID, it.Kind, it.Method, it.URL, status,
now.Sub(it.PausedAt).Truncate(time.Second))
}
return strings.TrimRight(b.String(), "\n"), nil
}),
})
}
func registerReplace(r *capability.Registry, d Deps) {
+10 -3
View File
@@ -367,7 +367,7 @@ func registerDetect(r *capability.Registry, d Deps) {
}`),
ArgsExample: json.RawMessage(`{"scope":"all"}`),
MaxOutputBytes: 8 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args detectRescanArgs) (any, error) {
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args detectRescanArgs) (any, error) {
if d.Scanner == nil {
return nil, fmt.Errorf("the detection scanner is unavailable")
}
@@ -402,8 +402,15 @@ func registerDetect(r *capability.Registry, d Deps) {
return nil, err
}
capability.RecordChange(ctx, "rescan scope=%s host=%s", scope, orDefault(req.Host, "*"))
return fmt.Sprintf("rescan started jobId=%s kind=%s total=%d; poll findings_list for results",
status.JobID, status.Kind, status.Total), nil
// Only point at findings_list if this token actually holds it; a token that
// may tune detection but not read findings would otherwise spend a call
// discovering the tool does not exist for it.
next := "poll findings_list for results"
if !p.Can("findings.list") {
next = "results are visible to the operator in Joro's Detect tab, not from here"
}
return fmt.Sprintf("rescan started jobId=%s kind=%s total=%d; %s",
status.JobID, status.Kind, status.Total, next), nil
}),
})
}
+76
View File
@@ -0,0 +1,76 @@
package capreg
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/BishopFox/joro/internal/capability"
)
type contextClearArgs struct {
Host string `json:"host"`
}
func registerExecContext(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "context.get",
Class: capability.ClassContext,
Title: "Read this token's session cookies",
Description: "The cookies http_resend and http_batch are holding for you, by host. Set-Cookie from " +
"every send is recorded here and replayed on later sends to the same host, so a login through " +
"http_resend keeps subsequent requests authenticated without you handling the cookie. Values are " +
"shown only if this token has credential visibility; names and hosts always are.",
InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
ArgsExample: json.RawMessage(`{}`),
MaxOutputBytes: 16 << 10,
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, _ struct{}) (any, error) {
if d.Contexts == nil {
return nil, fmt.Errorf("the execution context is unavailable")
}
cookies := d.Contexts.List(p.TokenID, p.AllowCredentials)
if len(cookies) == 0 {
return "(no session cookies held; send an authenticated request with http_resend to populate this)", nil
}
var b strings.Builder
fmt.Fprintf(&b, "n=%d\n", len(cookies))
for _, c := range cookies {
if p.AllowCredentials {
fmt.Fprintf(&b, "%s %s=%s\n", c.Host, c.Name, c.Value)
} else {
fmt.Fprintf(&b, "%s %s=<withheld>\n", c.Host, c.Name)
}
}
return strings.TrimRight(b.String(), "\n"), nil
}),
})
r.MustRegister(capability.Capability{
ID: "context.clear",
Class: capability.ClassContext,
Title: "Drop this token's session cookies",
Mutating: true,
Description: "Forget the cookies held for you, for one host or for all of them. Use this to test as an " +
"unauthenticated user after logging in, or before authenticating as a different account. Affects " +
"only this token's own session; the operator's browser is untouched.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"host": {"type":"string","description":"Clear only this host. Omit to clear everything."}
},
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{}`),
MaxOutputBytes: 4 << 10,
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args contextClearArgs) (any, error) {
if d.Contexts == nil {
return nil, fmt.Errorf("the execution context is unavailable")
}
n := d.Contexts.Clear(p.TokenID, args.Host)
scope := orDefault(strings.TrimSpace(args.Host), "all hosts")
capability.RecordChange(ctx, "clear session cookies for %s (%d)", scope, n)
return fmt.Sprintf("cleared %d cookie(s) for %s", n, scope), nil
}),
})
}
+431
View File
@@ -0,0 +1,431 @@
package capreg
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/fuzzer"
"github.com/BishopFox/joro/internal/httptools"
)
// Caps on an agent-started campaign, well below what the Fuzz tab allows.
//
// The wordlist bound is what bounds the request count: with one wordlist the
// campaign is one request per entry, so there is no cartesian product to guard
// against. Anything larger belongs in the UI, where the operator is watching and
// can upload a real wordlist rather than shipping one through a tool call.
const (
maxFuzzWords = 500
maxFuzzWordBytes = 64 << 10
maxFuzzThreads = 10
defaultFuzzConc = 4
defaultFuzzResults = 50
maxFuzzResults = 200
)
type fuzzStartArgs struct {
Ref int `json:"ref"`
Edits []httptools.Edit `json:"edits"`
Wordlist []string `json:"wordlist"`
Scheme string `json:"scheme"`
Host string `json:"host"`
Threads int `json:"threads"`
RatePerSec float64 `json:"ratePerSec"`
}
type fuzzCampaignArgs struct {
ID string `json:"id"`
}
type fuzzResultsArgs struct {
ID string `json:"id"`
Status string `json:"status"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func registerFuzzer(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "fuzzer.start",
Class: capability.ClassFuzzer,
Title: "Start a fuzzing campaign",
SendsTraffic: true,
Description: "Run a wordlist against one position in a captured request. Insert the marker FUZZ with an " +
"edit — setPath, setQuery, setHeader or replaceInBody — and every wordlist entry is substituted for " +
"it in turn. Returns a campaign id immediately; poll fuzzer_status and read fuzzer_results. " +
"Wordlists are capped at 500 entries, so this is for a focused sweep; larger runs belong in the " +
"Fuzz tab. Unlike http_resend, fuzzer traffic does not pass through the proxy, so results are not " +
"captured into history and have no seq — the metrics returned here are the whole record.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"ref": {"type":"integer","description":"Seq of the captured request to use as the template."},
"edits": {"type":"array","items":` + editSchema + `,"description":"Edits that must introduce the marker FUZZ, e.g. {\"op\":\"setQuery\",\"name\":\"id\",\"value\":\"FUZZ\"}."},
"wordlist": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":500,"description":"Payloads substituted for FUZZ, one request each. Max 500 entries, 64 KB total."},
"scheme": {"type":"string","enum":["http","https"],"description":"Override the scheme; defaults to the captured request's."},
"host": {"type":"string","description":"Override the host. Must pass this token's scope and host whitelist."},
"threads": {"type":"integer","minimum":1,"maximum":10,"description":"Concurrent requests; default 4."},
"ratePerSec": {"type":"number","minimum":0,"maximum":50,"description":"Requests per second across all threads; 0 means unlimited."}
},
"required":["ref","wordlist"],
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"ref":1204,"edits":[{"op":"setPath","value":"/admin/FUZZ"}],` +
`"wordlist":["users","config","backup"],"ratePerSec":10}`),
MaxOutputBytes: 8 << 10,
Target: capability.TypedTarget(func(args fuzzStartArgs) (capability.Target, error) {
return resolveTarget(d, args.Ref, args.Scheme, args.Host, args.Edits)
}),
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args fuzzStartArgs) (any, error) {
if d.Fuzzer == nil || d.Transport == nil {
return nil, fmt.Errorf("the fuzzer is unavailable")
}
if d.Store == nil {
return nil, fmt.Errorf("capture store is unavailable")
}
item := d.Store.GetBySeq(args.Ref)
if item == nil {
return nil, fmt.Errorf("no captured request with seq %d", args.Ref)
}
if len(item.ReqRaw) == 0 {
return nil, fmt.Errorf("request %d has no captured bytes to fuzz", args.Ref)
}
words, err := checkWordlist(args.Wordlist)
if err != nil {
return nil, err
}
raw, err := httptools.ApplyEdits(item.ReqRaw, args.Edits)
if err != nil {
return nil, err
}
positions := fuzzer.DetectPositions(raw)
if len(positions) == 0 {
return nil, fmt.Errorf("the request contains no FUZZ marker after your edits. Add one with an " +
`edit, e.g. {"op":"setQuery","name":"id","value":"FUZZ"} or ` +
`{"op":"replaceInBody","find":"8291","value":"FUZZ"}`)
}
scheme, host, _, _, err := httptools.TargetOf(d.Store, args.Ref, args.Scheme, args.Host, args.Edits)
if err != nil {
return nil, err
}
cfg := fuzzer.Config{
RawRequest: raw,
Scheme: scheme,
Host: host,
Wordlist: words,
Positions: positions,
// One wordlist across however many markers the edits introduced, so a
// repeated id is substituted consistently rather than needing a map.
AttackMode: fuzzer.AttackSpray,
Threads: clampInt(args.Threads, defaultFuzzConc, 1, maxFuzzThreads),
RateLimit: min(max(args.RatePerSec, 0), 50),
UpdateContentLength: true,
}
if len(positions) > 1 {
cfg.Wordlists = map[string][]string{positions[0]: words}
}
// A campaign outlives the invocation that started it, so it runs under the
// server-lifetime context rather than this call's 30s timeout.
bg := context.Background()
if d.BgCtx != nil {
bg = d.BgCtx()
}
runCtx, cancel := context.WithCancel(bg)
campaign := fuzzer.NewCampaign(cfg, cancel)
d.Fuzzer.Add(campaign)
go fuzzer.Run(runCtx, campaign, d.Transport, d.Broadcast)
capability.RecordChange(ctx, "start fuzz campaign %s on %s (%d requests, %s)",
campaign.ID, host, campaign.Total, strings.Join(positions, ","))
return fmt.Sprintf("started campaign id=%s total=%d positions=%s target=%s://%s\n"+
"poll: fuzzer_status{id:%q} read: fuzzer_results{id:%q}",
campaign.ID, campaign.Total, strings.Join(positions, ","), scheme, host,
campaign.ID, campaign.ID), nil
}),
})
r.MustRegister(capability.Capability{
ID: "fuzzer.status",
Class: capability.ClassFuzzer,
Title: "Check fuzzing campaign progress",
Description: "Progress of one campaign, or a list of them when id is omitted: status, requests completed " +
"out of total, and error count. Poll this rather than fuzzer_results while a campaign is running.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"id": {"type":"string","description":"Campaign id. Omit to list every campaign."}
},
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{}`),
MaxOutputBytes: 16 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args fuzzCampaignArgs) (any, error) {
if d.Fuzzer == nil {
return nil, fmt.Errorf("the fuzzer is unavailable")
}
if strings.TrimSpace(args.ID) == "" {
campaigns := d.Fuzzer.List()
if len(campaigns) == 0 {
return "(no campaigns)", nil
}
var b strings.Builder
for _, c := range campaigns {
fmt.Fprintf(&b, "%s %s %d/%d errors=%d\n",
c.ID, c.Status, c.Completed, c.Total, c.Errors)
}
return strings.TrimRight(b.String(), "\n"), nil
}
c := d.Fuzzer.Get(args.ID)
if c == nil {
return nil, fmt.Errorf("no campaign with id %s", args.ID)
}
return fmt.Sprintf("id=%s status=%s completed=%d/%d errors=%d",
c.ID, c.Status, c.Completed, c.Total, c.Errors), nil
}),
})
r.MustRegister(capability.Capability{
ID: "fuzzer.results",
Class: capability.ClassFuzzer,
Title: "Read fuzzing campaign results",
Description: "Results for a campaign as a comparison table: payload, status, size, words, lines and " +
"duration. Rows are grouped by status and size in the summary, so the outliers are named rather " +
"than left for you to spot. Response bodies are not stored by the fuzzer; to inspect one, resend " +
"that payload with http_resend, which does capture it.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"id": {"type":"string","description":"Campaign id."},
"status": {"type":"string","description":"Keep only these status codes, comma-separated, e.g. \"200,403\"."},
"offset": {"type":"integer","minimum":0,"description":"Row offset for paging."},
"limit": {"type":"integer","minimum":1,"maximum":200,"description":"Rows to return; default 50."}
},
"required":["id"],
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"id":"6f1c...","status":"200,403"}`),
MaxOutputBytes: 128 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args fuzzResultsArgs) (any, error) {
if d.Fuzzer == nil {
return nil, fmt.Errorf("the fuzzer is unavailable")
}
c := d.Fuzzer.Get(args.ID)
if c == nil {
return nil, fmt.Errorf("no campaign with id %s", args.ID)
}
limit := clampInt(args.Limit, defaultFuzzResults, 1, maxFuzzResults)
return renderFuzzResults(c, keepStatuses(args.Status), args.Offset, limit), nil
}),
})
r.MustRegister(capability.Capability{
ID: "fuzzer.stop",
Class: capability.ClassFuzzer,
Title: "Stop a fuzzing campaign",
Mutating: true,
Description: "Cancel a running campaign. Results already collected are kept and stay readable with " +
"fuzzer_results.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"id": {"type":"string","description":"Campaign id."}
},
"required":["id"],
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"id":"6f1c..."}`),
MaxOutputBytes: 4 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args fuzzCampaignArgs) (any, error) {
if d.Fuzzer == nil {
return nil, fmt.Errorf("the fuzzer is unavailable")
}
c := d.Fuzzer.Get(args.ID)
if c == nil {
return nil, fmt.Errorf("no campaign with id %s", args.ID)
}
if c.Status != fuzzer.StatusRunning {
return fmt.Sprintf("campaign %s is already %s", c.ID, c.Status), nil
}
c.Cancel()
capability.RecordChange(ctx, "stop fuzz campaign %s after %d/%d", c.ID, c.Completed, c.Total)
return fmt.Sprintf("stopping campaign %s at %d/%d", c.ID, c.Completed, c.Total), nil
}),
})
}
// checkWordlist normalizes and bounds an inline wordlist.
func checkWordlist(words []string) ([]string, error) {
out := make([]string, 0, len(words))
total := 0
for _, w := range words {
if w == "" {
continue
}
total += len(w)
out = append(out, w)
}
switch {
case len(out) == 0:
return nil, fmt.Errorf("wordlist is required and must contain at least one non-empty entry")
case len(out) > maxFuzzWords:
return nil, fmt.Errorf("wordlist has %d entries, over the %d limit for an automation campaign; "+
"narrow it, or upload the full list and run it from the Fuzz tab", len(out), maxFuzzWords)
case total > maxFuzzWordBytes:
return nil, fmt.Errorf("wordlist is %d bytes, over the %d byte limit", total, maxFuzzWordBytes)
}
return out, nil
}
// keepStatuses parses a comma-separated status list. Nil means keep everything.
func keepStatuses(s string) map[int]bool {
parts := splitCSV(s)
if len(parts) == 0 {
return nil
}
keep := make(map[int]bool, len(parts))
for _, p := range parts {
if n, err := strconv.Atoi(p); err == nil {
keep[n] = true
}
}
if len(keep) == 0 {
return nil
}
return keep
}
func renderFuzzResults(c *fuzzer.Campaign, keep map[int]bool, offset, limit int) string {
all := c.Results()
rows := make([]fuzzer.Result, 0, len(all))
for _, r := range all {
if keep == nil || keep[r.StatusCode] {
rows = append(rows, r)
}
}
total := len(rows)
if offset < total {
end := min(offset+limit, total)
rows = rows[offset:end]
} else {
rows = nil
}
var b strings.Builder
fmt.Fprintf(&b, "campaign=%s status=%s completed=%d/%d errors=%d n=%d/%d off=%d\n",
c.ID, c.Status, c.Completed, c.Total, c.Errors, len(rows), total, offset)
if summary := fuzzGroupSummary(all); summary != "" {
b.WriteString(summary + "\n")
}
if len(rows) == 0 {
b.WriteString("(no results yet)")
return b.String()
}
widths := []int{0, 0, 0, 0, 0}
cells := make([][]string, 0, len(rows))
for _, r := range rows {
payload := r.Payload
if payload == "" && len(r.Payloads) > 0 {
payload = joinPayloads(r.Payloads)
}
row := []string{
trunc(payload, 40),
statusOrErr(r),
strconv.Itoa(r.Size),
strconv.Itoa(r.Words),
strconv.Itoa(r.Lines),
strconv.FormatInt(r.DurationMs, 10),
}
for i := range widths {
widths[i] = max(widths[i], len(row[i]))
}
cells = append(cells, row)
}
b.WriteString(pad("payload", widths[0]) + " " + pad("status", widths[1]) + " " +
pad("len", widths[2]) + " " + pad("words", widths[3]) + " " +
pad("lines", widths[4]) + " ms\n")
for _, r := range cells {
fmt.Fprintf(&b, "%s %s %s %s %s %s\n",
pad(r[0], widths[0]), pad(r[1], widths[1]), pad(r[2], widths[2]),
pad(r[3], widths[3]), pad(r[4], widths[4]), r[5])
}
return strings.TrimRight(b.String(), "\n")
}
// fuzzGroupSummary names the shape of the whole result set, so the answer does not
// have to be inferred from the rows. The dominant status+size group is the
// uninteresting baseline; everything else is worth reading.
func fuzzGroupSummary(all []fuzzer.Result) string {
if len(all) == 0 {
return ""
}
type key struct {
status, size int
}
counts := map[key]int{}
for _, r := range all {
if r.Error == "" {
counts[key{r.StatusCode, r.Size}]++
}
}
if len(counts) <= 1 {
return ""
}
groups := make([]key, 0, len(counts))
for k := range counts {
groups = append(groups, k)
}
sort.Slice(groups, func(i, j int) bool {
if counts[groups[i]] != counts[groups[j]] {
return counts[groups[i]] > counts[groups[j]]
}
return groups[i].status < groups[j].status
})
parts := make([]string, 0, len(groups))
for i, g := range groups {
if i >= 6 {
parts = append(parts, fmt.Sprintf("+%d more groups", len(groups)-i))
break
}
label := fmt.Sprintf("%d/%dB x%d", g.status, g.size, counts[g])
if i == 0 {
label += " (baseline)"
}
parts = append(parts, label)
}
return "groups: " + strings.Join(parts, " ")
}
func statusOrErr(r fuzzer.Result) string {
if r.Error != "" {
return "err"
}
return strconv.Itoa(r.StatusCode)
}
func joinPayloads(m map[string]string) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, m[k])
}
return strings.Join(parts, "|")
}
+3 -1
View File
@@ -35,7 +35,9 @@ func registerHistory(r *capability.Registry, d Deps) {
"content-type keyword and path. Requests are addressed everywhere else by their integer seq. " +
"Returns no request or response bodies — use http_read, http_search or http_fingerprint for those. " +
"The content filter greps raw request and response bytes inside the store, so it is a cheap " +
"corpus-wide search that never copies a body.",
"corpus-wide search that never copies a body. It matches the bytes as captured, including header " +
"values that http_read and http_search mask, so it reports whether a string is present without " +
"returning it.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{` + historyFilterProps + `,
+38 -14
View File
@@ -34,7 +34,13 @@ const editSchema = `{
func registerHTTP(r *capability.Registry, d Deps) {
sendDeps := httptools.SendDeps{ProxyAddr: d.ProxyAddr, CA: d.CA, Store: d.Store}
resendDeps := httptools.ResendDeps{Send: sendDeps, Store: d.Store}
// The jar is per-principal, so TokenID is filled in per invocation.
resendDeps := func(p capability.Principal) httptools.ResendDeps {
return httptools.ResendDeps{
Send: sendDeps, Store: d.Store,
Contexts: d.Contexts, TokenID: p.TokenID,
}
}
r.MustRegister(capability.Capability{
ID: "http.fingerprint",
@@ -72,7 +78,9 @@ func registerHTTP(r *capability.Registry, d Deps) {
"reports totalLength, offset, returned and truncated so you can page. A negative offset reads " +
"from the end, which is the cheapest way to see the bottom of an error page. Compressed bodies " +
"are decompressed by default; section \"raw\" is always byte-exact. Binary data is returned as a " +
"hex dump when small, base64 when not.",
"hex dump when small, base64 when not. Unless this token has credential visibility, the values " +
"of Authorization, Cookie, Set-Cookie and similar headers are overwritten with '*' — the header " +
"is present, and offsets are unaffected, but the value is withheld and a redacted line names it.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
@@ -89,7 +97,7 @@ func registerHTTP(r *capability.Registry, d Deps) {
}`),
ArgsExample: json.RawMessage(`{"ref":1204,"offset":-2048}`),
MaxOutputBytes: 128 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args httptools.ReadArgs) (any, error) {
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args httptools.ReadArgs) (any, error) {
if d.Store == nil {
return nil, fmt.Errorf("capture store is unavailable")
}
@@ -97,10 +105,16 @@ func registerHTTP(r *capability.Registry, d Deps) {
if item == nil {
return nil, fmt.Errorf("no captured request with seq %d", args.Ref)
}
res, err := httptools.ReadRange(item.ReqRaw, item.RespRaw, args)
reqRaw, respRaw := item.ReqRaw, item.RespRaw
var redacted []string
if !p.AllowCredentials {
reqRaw, respRaw, redacted = httptools.MaskPair(reqRaw, respRaw)
}
res, err := httptools.ReadRange(reqRaw, respRaw, args)
if err != nil {
return nil, err
}
res.Redacted = redacted
return res.Render(), nil
}),
})
@@ -112,7 +126,9 @@ func registerHTTP(r *capability.Registry, d Deps) {
Description: "Find a string or regex across captured traffic and return match offsets with surrounding " +
"context, without returning any bodies. With ref set it searches one request; without it, it " +
"searches a filtered corpus. Matching is against the bytes as captured, so a compressed response " +
"will not match a plaintext pattern unless deep is set. Matching is case-insensitive by default.",
"will not match a plaintext pattern unless deep is set. Matching is case-insensitive by default. " +
"Unless this token has credential visibility, sensitive header values are masked before matching, " +
"so a pattern will not match inside one.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
@@ -132,11 +148,15 @@ func registerHTTP(r *capability.Registry, d Deps) {
}`),
ArgsExample: json.RawMessage(`{"pattern":"X-Debug-Token"}`),
MaxOutputBytes: 128 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args httptools.SearchArgs) (any, error) {
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args httptools.SearchArgs) (any, error) {
if d.Store == nil {
return nil, fmt.Errorf("capture store is unavailable")
}
return httptools.SearchCorpus(httptools.SearchDeps{Store: d.Store, Scope: d.Scope}, args)
return httptools.SearchCorpus(httptools.SearchDeps{
Store: d.Store,
Scope: d.Scope,
MaskCredentials: !p.AllowCredentials,
}, args)
}),
})
@@ -196,7 +216,9 @@ func registerHTTP(r *capability.Registry, d Deps) {
"fingerprint, not a body — read the result with http_read or compare it with http_diff. " +
"Redirects are never followed: read the Location header and issue a second call, which is " +
"checked against scope in its own right. Match & Replace and Custom Data rules apply, so the " +
"bytes on the wire may differ from what you specify.",
"bytes on the wire may differ from what you specify. Session cookies held for this token are " +
"applied unless the request already carries that cookie or your edits touch the Cookie header, " +
"and any Set-Cookie in the response is recorded for later sends.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
@@ -205,7 +227,8 @@ func registerHTTP(r *capability.Registry, d Deps) {
"scheme": {"type":"string","enum":["http","https"],"description":"Override the scheme; defaults to the captured request's."},
"host": {"type":"string","description":"Override the host; defaults to the captured request's. Must pass this token's scope and host whitelist."},
"updateContentLength": {"type":"boolean","description":"Recalculate Content-Length after editing; default true."},
"timeoutMs": {"type":"integer","minimum":1000,"maximum":60000,"description":"Per-request timeout; default 15000."}
"timeoutMs": {"type":"integer","minimum":1000,"maximum":60000,"description":"Per-request timeout; default 15000."},
"useContext": {"type":"boolean","description":"Apply and record this token's session cookies; default true. Set false to send exactly what you specified, e.g. when testing unauthenticated access."}
},
"required":["ref"],
"additionalProperties":false
@@ -216,11 +239,11 @@ func registerHTTP(r *capability.Registry, d Deps) {
Target: capability.TypedTarget(func(args httptools.ResendArgs) (capability.Target, error) {
return resolveTarget(d, args.Ref, args.Scheme, args.Host, args.Edits)
}),
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args httptools.ResendArgs) (any, error) {
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args httptools.ResendArgs) (any, error) {
if d.Store == nil {
return nil, fmt.Errorf("capture store is unavailable")
}
return httptools.Resend(ctx, resendDeps, args)
return httptools.Resend(ctx, resendDeps(p), args)
}),
})
@@ -254,7 +277,8 @@ func registerHTTP(r *capability.Registry, d Deps) {
"concurrency": {"type":"integer","minimum":1,"maximum":10,"description":"Parallel requests; default 4."},
"ratePerSec": {"type":"number","minimum":0,"maximum":50,"description":"Requests per second across all workers; 0 means unlimited."},
"timeoutMs": {"type":"integer","minimum":1000,"maximum":60000,"description":"Per-request timeout; default 10000."},
"totalBudgetMs": {"type":"integer","minimum":1000,"maximum":120000,"description":"Wall-clock budget for the whole batch; default 60000."}
"totalBudgetMs": {"type":"integer","minimum":1000,"maximum":120000,"description":"Wall-clock budget for the whole batch; default 60000."},
"useContext": {"type":"boolean","description":"Apply this token's session cookies to every variant; default true. Set-Cookie from a batch is never recorded, so all variants start from the same session."}
},
"required":["ref","variants"],
"additionalProperties":false
@@ -266,11 +290,11 @@ func registerHTTP(r *capability.Registry, d Deps) {
Target: capability.TypedTarget(func(args httptools.BatchArgs) (capability.Target, error) {
return resolveTarget(d, args.Ref, args.Scheme, args.Host, nil)
}),
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args httptools.BatchArgs) (any, error) {
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args httptools.BatchArgs) (any, error) {
if d.Store == nil {
return nil, fmt.Errorf("capture store is unavailable")
}
return httptools.Batch(ctx, resendDeps, args)
return httptools.Batch(ctx, resendDeps(p), args)
}),
})
}
+54
View File
@@ -0,0 +1,54 @@
package capreg
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/BishopFox/joro/internal/capability"
)
// instance.get is the orientation call. Without it an agent has to spend four
// separate invocations working out what it is connected to before it can start.
func registerInstance(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "instance.get",
Class: capability.ClassInstance,
Title: "Describe this Joro instance",
Description: "What this Joro instance is: version, proxy address, the active project, how much traffic " +
"has been captured, whether scope, interception and detection are on, and how many findings exist. " +
"Call this first — it is one request and it tells you whether the state you are about to reason " +
"about is empty, scoped, or already triaged.",
InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
ArgsExample: json.RawMessage(`{}`),
MaxOutputBytes: 8 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, _ struct{}) (any, error) {
var b strings.Builder
project := "(none)"
if d.ActiveProject != nil {
project = orDefault(d.ActiveProject(), "(unsaved session)")
}
fmt.Fprintf(&b, "joro %s proxy=%s project=%s\n",
orDefault(d.Version, "unknown"), orDefault(d.ProxyAddr, "unknown"), project)
if d.Store != nil {
fmt.Fprintf(&b, "captured=%d\n", d.Store.Count())
}
if d.Scope != nil {
fmt.Fprintf(&b, "scope=%s rules=%d\n", onOff(d.Scope.IsEnabled()), d.Scope.RuleCount())
}
if d.Intercept != nil {
fmt.Fprintf(&b, "intercept requests=%s responses=%s queued=%d\n",
onOff(d.Intercept.IsEnabled()), onOff(d.Intercept.IsResponseEnabled()),
len(d.Intercept.List()))
}
if d.Engine != nil && d.Findings != nil {
fmt.Fprintf(&b, "detect=%s findings=%d\n",
onOff(d.Engine.IsEnabled()), d.Findings.Count())
}
return strings.TrimRight(b.String(), "\n"), nil
}),
})
}
+257
View File
@@ -0,0 +1,257 @@
package capreg
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/proxy"
"github.com/BishopFox/joro/internal/shell"
)
// Execution and C2. These are registered only when Joro is started with
// --automation-privileged, and no profile grants one even then.
//
// The containment differs between the two families and the descriptions say so.
// exec.webshell dials a web target, so it carries a Target extractor and the scope
// guard applies to it exactly as it does to http.resend. The c2 capabilities reach
// the operator's own team server, which scope rules do not describe — scope-guarding
// them would deny every call rather than bound anything — so the launch flag and the
// explicit grant are their whole control.
const maxWebshellCommand = 4 << 10
type webshellArgs struct {
Target string `json:"target"`
Webshell string `json:"webshell"`
AuthKey string `json:"authKey"`
Command string `json:"command"`
}
type c2CommandArgs struct {
Input string `json:"input"`
}
func registerPrivileged(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "exec.webshell",
Class: capability.ClassExec,
Title: "Run a command through a deployed web shell",
Mutating: true,
Privileged: true,
SendsTraffic: true,
Description: "Execute a shell command on a host through a web shell already deployed there, using the " +
"auth key from when it was generated. Held to this token's scope and host whitelist. Output is " +
"whatever the shell returns, truncated to the output cap.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"target": {"type":"string","description":"Full URL of the deployed shell, e.g. \"https://target.example/uploads/s.php\"."},
"webshell": {"type":"string","description":"Shell format: php, asp, aspx, ashx, jsp or cfm."},
"authKey": {"type":"string","description":"The auth key generated with the shell."},
"command": {"type":"string","description":"The command to run."}
},
"required":["target","webshell","authKey","command"],
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"target":"https://target.example/uploads/s.php","webshell":"php",` +
`"authKey":"3f2a...","command":"id"}`),
MaxOutputBytes: 64 << 10,
Target: capability.TypedTarget(func(args webshellArgs) (capability.Target, error) {
u, err := url.Parse(strings.TrimSpace(args.Target))
if err != nil || u.Host == "" {
return capability.Target{}, fmt.Errorf("target must be a full URL including scheme and host")
}
return capability.Target{Host: u.Host, Method: "POST", Path: u.Path}, nil
}),
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args webshellArgs) (any, error) {
if args.Target == "" || args.Webshell == "" || args.AuthKey == "" || args.Command == "" {
return nil, fmt.Errorf("target, webshell, authKey and command are all required")
}
if len(args.Command) > maxWebshellCommand {
return nil, fmt.Errorf("command is %d bytes, over the %d byte limit",
len(args.Command), maxWebshellCommand)
}
client := proxy.NewHTTPClient("", d.Transport)
out, err := shell.ExecuteCommand(args.Target, args.Webshell, args.AuthKey, args.Command, client)
capability.RecordChange(ctx, "webshell exec on %s", args.Target)
if err != nil {
return nil, err
}
return out, nil
}),
})
registerSliver(r, d)
registerMythic(r, d)
}
func registerSliver(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "c2.sliver.read",
Class: capability.ClassC2,
Privileged: true,
Title: "List Sliver sessions and beacons",
Description: "Whether Joro is connected to a Sliver server, and the sessions and beacons it can see. " +
"Read-only: it runs nothing on an implant.",
InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
ArgsExample: json.RawMessage(`{}`),
MaxOutputBytes: 32 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, _ struct{}) (any, error) {
if d.Sliver == nil {
return nil, fmt.Errorf("the Sliver client is unavailable")
}
if !d.Sliver.IsConnected() {
return "not connected to a Sliver server", nil
}
var b strings.Builder
b.WriteString("connected\n")
sessions, err := d.Sliver.ListSessions(ctx)
if err != nil {
return nil, err
}
for _, s := range sessions {
fmt.Fprintf(&b, "session %s %s %s/%s user=%s from=%s\n",
s.ID, s.Hostname, s.OS, s.Arch, s.Username, s.RemoteAddress)
}
beacons, err := d.Sliver.ListBeacons(ctx)
if err == nil {
for _, bn := range beacons {
fmt.Fprintf(&b, "beacon %s %s %s/%s\n", bn.ID, bn.Hostname, bn.OS, bn.Arch)
}
}
if len(sessions) == 0 && len(beacons) == 0 {
b.WriteString("(no sessions or beacons)")
}
return strings.TrimRight(b.String(), "\n"), nil
}),
})
r.MustRegister(capability.Capability{
ID: "c2.sliver.command",
Class: capability.ClassC2,
Mutating: true,
Privileged: true,
Title: "Run a Sliver command",
Description: "Issue a command to the connected Sliver server, in the same text form the Execute tab " +
"accepts: `sessions`, `use <id>` to select an implant, then commands that run on it. Scope and " +
"this token's host whitelist do not apply — they describe web targets, not a C2 server — so the " +
"grant itself is the only limit on what this reaches. Binary results are cached for the operator " +
"rather than returned.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"input": {"type":"string","description":"The command line, e.g. \"sessions\", \"use 3\", \"ls /tmp\"."}
},
"required":["input"],
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"input":"sessions"}`),
MaxOutputBytes: 64 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args c2CommandArgs) (any, error) {
if d.Sliver == nil {
return nil, fmt.Errorf("the Sliver client is unavailable")
}
input := strings.TrimSpace(args.Input)
if input == "" {
return nil, fmt.Errorf("input is required")
}
res := d.Sliver.Dispatch(ctx, input)
capability.RecordChange(ctx, "sliver: %s", trunc(input, 120))
return renderC2Result(res.Output, res.Error, res.Filename), nil
}),
})
}
func registerMythic(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "c2.mythic.read",
Class: capability.ClassC2,
Privileged: true,
Title: "List Mythic callbacks",
Description: "Whether Joro is connected to a Mythic server, and the callbacks it can see. Read-only: it " +
"tasks nothing.",
InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
ArgsExample: json.RawMessage(`{}`),
MaxOutputBytes: 32 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, _ struct{}) (any, error) {
if d.Mythic == nil {
return nil, fmt.Errorf("the Mythic client is unavailable")
}
if !d.Mythic.IsConnected() {
return "not connected to a Mythic server", nil
}
callbacks, err := d.Mythic.ListCallbacks(ctx)
if err != nil {
return nil, err
}
if len(callbacks) == 0 {
return "connected\n(no callbacks)", nil
}
var b strings.Builder
b.WriteString("connected\n")
for _, c := range callbacks {
fmt.Fprintf(&b, "callback %d %s@%s %s/%s ip=%s payload=%s\n",
c.DisplayID, c.User, c.Host, c.OS, c.Architecture, c.IP, c.PayloadType)
}
return strings.TrimRight(b.String(), "\n"), nil
}),
})
r.MustRegister(capability.Capability{
ID: "c2.mythic.command",
Class: capability.ClassC2,
Mutating: true,
Privileged: true,
Title: "Run a Mythic command",
Description: "Issue a command to the connected Mythic server, in the same text form the Execute tab " +
"accepts: `callbacks`, `use <display id>` to select one, then commands its agent has loaded. Scope " +
"and this token's host whitelist do not apply — they describe web targets, not a C2 server — so the " +
"grant itself is the only limit on what this reaches.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"input": {"type":"string","description":"The command line, e.g. \"callbacks\", \"use 4\", \"shell whoami\"."}
},
"required":["input"],
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"input":"callbacks"}`),
MaxOutputBytes: 64 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args c2CommandArgs) (any, error) {
if d.Mythic == nil {
return nil, fmt.Errorf("the Mythic client is unavailable")
}
input := strings.TrimSpace(args.Input)
if input == "" {
return nil, fmt.Errorf("input is required")
}
res := d.Mythic.Dispatch(ctx, input)
capability.RecordChange(ctx, "mythic: %s", trunc(input, 120))
return renderC2Result(res.Output, res.Error, res.Filename), nil
}),
})
}
// renderC2Result flattens a dispatcher result. A download is reported by name only:
// the bytes are cached server-side for the operator to retrieve from the UI, and
// pushing a binary through a tool result would be useless to a model and expensive.
func renderC2Result(output, errMsg, filename string) string {
var b strings.Builder
if output != "" {
b.WriteString(output)
}
if filename != "" {
fmt.Fprintf(&b, "\n[file %s is cached for the operator to download from the Execute tab]", filename)
}
if errMsg != "" {
fmt.Fprintf(&b, "\nerror: %s", errMsg)
}
if b.Len() == 0 {
return "(no output)"
}
return strings.TrimSpace(b.String())
}
+152
View File
@@ -0,0 +1,152 @@
package capreg
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/capability"
"github.com/BishopFox/joro/internal/proxy"
)
// WebSocket list caps. Payloads are previewed rather than returned whole: a single
// frame can be megabytes, and the reason to list frames is to find the interesting
// one, not to read all of them.
const (
defaultWSLimit = 50
maxWSLimit = 200
defaultWSPreview = 120
maxWSPreview = 1024
)
type wsListArgs struct {
Host string `json:"host"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Preview int `json:"preview"`
}
func registerWebSocket(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "websocket.list",
Class: capability.ClassWebSocket,
Title: "List captured WebSocket messages",
Description: "Frames captured from proxied WebSocket connections: connection, direction, opcode, length " +
"and a text preview. Binary payloads are reported by length only. Read-only — sending a frame is " +
"done by the operator from the Manipulate tab.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"host": {"type":"string","description":"Restrict to connections whose host contains this substring."},
"offset": {"type":"integer","minimum":0,"description":"Row offset for paging."},
"limit": {"type":"integer","minimum":1,"maximum":200,"description":"Rows to return; default 50."},
"preview": {"type":"integer","minimum":1,"maximum":1024,"description":"Characters of payload preview per row; default 120."}
},
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"limit":30}`),
MaxOutputBytes: 128 << 10,
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args wsListArgs) (any, error) {
if d.WSStore == nil {
return nil, fmt.Errorf("WebSocket capture is unavailable")
}
limit := clampInt(args.Limit, defaultWSLimit, 1, maxWSLimit)
preview := clampInt(args.Preview, defaultWSPreview, 1, maxWSPreview)
items, total := d.WSStore.List(proxy.WSMessageFilter{
Host: args.Host, Offset: args.Offset, Limit: limit,
})
return renderWSMessages(items, total, args.Offset, preview), nil
}),
})
}
func renderWSMessages(items []*proxy.CapturedWSMessage, total, offset, preview int) string {
if len(items) == 0 {
return "(no WebSocket messages captured)"
}
var b strings.Builder
fmt.Fprintf(&b, "n=%d/%d off=%d\n", len(items), total, offset)
widths := []int{0, 0, 0, 0, 0}
rows := make([][]string, 0, len(items))
for _, m := range items {
row := []string{
shortConnID(m.ConnectionID),
wsDirection(m.Direction),
wsOpcode(m.Opcode, m.IsText),
strconv.Itoa(m.PayloadLength),
m.Host,
wsPreview(m, preview),
}
for i := range widths {
widths[i] = max(widths[i], len(row[i]))
}
rows = append(rows, row)
}
b.WriteString(pad("conn", widths[0]) + " " + pad("dir", widths[1]) + " " +
pad("op", widths[2]) + " " + pad("len", widths[3]) + " " +
pad("host", widths[4]) + " payload\n")
for _, r := range rows {
fmt.Fprintf(&b, "%s %s %s %s %s | %s\n",
pad(r[0], widths[0]), pad(r[1], widths[1]), pad(r[2], widths[2]),
pad(r[3], widths[3]), pad(r[4], widths[4]), r[5])
}
return strings.TrimRight(b.String(), "\n")
}
// shortConnID trims the connection id to enough characters to group frames by
// connection, which is all a reader needs it for.
func shortConnID(id string) string {
if len(id) > 8 {
return id[:8]
}
return orDefault(id, "-")
}
func wsDirection(dir string) string {
if dir == "client_to_server" {
return "c->s"
}
return "s->c"
}
func wsOpcode(op byte, isText bool) string {
switch op {
case 0x1:
return "text"
case 0x2:
return "bin"
case 0x8:
return "close"
case 0x9:
return "ping"
case 0xA:
return "pong"
}
if isText {
return "text"
}
return "0x" + strconv.FormatUint(uint64(op), 16)
}
// wsPreview renders a frame payload on one line. A binary payload is base64 in the
// store, which is noise to a reader, so it is reported by length only.
func wsPreview(m *proxy.CapturedWSMessage, n int) string {
if !m.IsText {
return "<binary>"
}
s := strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
if r < 0x20 {
return -1
}
return r
}, m.Payload)
return trunc(strings.TrimSpace(s), n)
}
+119
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
uuid "github.com/hashicorp/go-uuid"
@@ -35,6 +36,29 @@ type findingsUpdateArgs struct {
Severity string `json:"severity"`
}
type findingsCreateArgs struct {
Host string `json:"host"`
Title string `json:"title"`
Severity string `json:"severity"`
Category string `json:"category"`
URL string `json:"url"`
Method string `json:"method"`
Evidence string `json:"evidence"`
Notes string `json:"notes"`
}
// agentFindingRuleID namespaces findings an agent reported by hand.
//
// Finding.ID is sha256(ruleID ‖ host ‖ groupDim), so a reserved rule ID is what
// keeps these in an identity space the scanner never produces — a rescan cannot
// collide with one, and the operator can tell at a glance which findings came from
// an agent rather than from a rule.
const (
agentFindingRuleID = "agent-reported"
maxFindingEvidence = 2 << 10
maxFindingTitle = 200
)
func registerWrites(r *capability.Registry, d Deps) {
r.MustRegister(capability.Capability{
ID: "notes.create",
@@ -119,6 +143,101 @@ func registerWrites(r *capability.Registry, d Deps) {
}),
})
r.MustRegister(capability.Capability{
ID: "findings.create",
Class: capability.ClassFindings,
Mutating: true,
Title: "Record a finding you confirmed",
Description: "Record something you established by testing, which the passive scanner cannot produce: an " +
"IDOR, an auth bypass, a business-logic flaw. It lands in the operator's Detect tab alongside " +
"scanner findings, attributed to you. Findings are keyed on host and title, so re-reporting the " +
"same one updates it rather than duplicating it. Use notes_create instead for an observation that " +
"is not a vulnerability.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"host": {"type":"string","description":"Affected host, e.g. \"api.target.com\"."},
"title": {"type":"string","description":"Short finding name, e.g. \"IDOR on /v2/orders/{id}\". This plus host is the identity."},
"severity": {"type":"string","enum":["critical","high","medium","low","info"],"description":"critical: high-grade PII or severe compromise (RCE, auth bypass). high: credentials, API keys, low-level PII. medium: the catch-all. low: a minor weakness. info: a surface, not exploitable."},
"category": {"type":"string","enum":["secrets","pii","credentials","access","disclosure","headers","cookies"],"description":"Defaults to access."},
"url": {"type":"string","description":"The URL that demonstrates it."},
"method": {"type":"string","description":"HTTP method that demonstrates it."},
"evidence": {"type":"string","description":"The observation that proves it: a response fragment, a status change, an id that was not yours."},
"notes": {"type":"string","description":"Reproduction steps or context for the operator."}
},
"required":["host","title","severity","evidence"],
"additionalProperties":false
}`),
ArgsExample: json.RawMessage(`{"host":"api.target.com","title":"IDOR on /v2/orders/{id}","severity":"high",` +
`"category":"access","url":"https://api.target.com/v2/orders/8292","method":"GET",` +
`"evidence":"200 with another tenant's order; only the id changed."}`),
MaxOutputBytes: 8 << 10,
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args findingsCreateArgs) (any, error) {
if d.Findings == nil {
return nil, fmt.Errorf("detection is unavailable")
}
host := strings.TrimSpace(args.Host)
title := strings.TrimSpace(args.Title)
evidence := strings.TrimSpace(args.Evidence)
if host == "" || title == "" || evidence == "" {
return nil, fmt.Errorf("host, title and evidence are all required and must not be empty")
}
if len(title) > maxFindingTitle {
return nil, fmt.Errorf("title is %d characters, over the %d limit; put the detail in evidence or notes",
len(title), maxFindingTitle)
}
if len(evidence) > maxFindingEvidence {
return nil, fmt.Errorf("evidence is %d bytes, over the %d byte limit; summarize, and put the "+
"detail in notes", len(evidence), maxFindingEvidence)
}
sev := detect.Severity(args.Severity)
if !sev.Valid() {
return nil, fmt.Errorf("severity %q is not one of critical, high, medium, low, info", args.Severity)
}
cat := detect.Category(orDefault(args.Category, string(detect.CategoryAccess)))
if !cat.Valid() {
return nil, fmt.Errorf("category %q is not one of secrets, pii, credentials, access, disclosure, headers, cookies", args.Category)
}
author := "agent"
if p.TokenName != "" {
author = "agent:" + p.TokenName
}
now := time.Now()
f := detect.Finding{
ID: detect.FindingID(agentFindingRuleID, host, title),
RuleID: agentFindingRuleID,
RuleName: title,
Category: cat,
Severity: sev,
Confidence: detect.ConfidenceHigh,
Target: detect.TargetMessage,
Host: host,
Method: strings.ToUpper(strings.TrimSpace(args.Method)),
URL: strings.TrimSpace(args.URL),
Detail: "reported by " + author,
Evidence: evidence,
Notes: strings.TrimSpace(args.Notes),
FirstSeen: now,
LastSeen: now,
// Offsets refer to a captured document; this finding has none.
EvidenceOffset: -1,
}
saved, isNew := d.Findings.Upsert(f)
capability.RecordChange(ctx, "report finding %s on %s (%s)", title, host, sev)
broadcast(d, "detect.finding", map[string]any{"finding": saved, "isNew": isNew})
broadcast(d, "detect.summary", d.Findings.Summary(ruleEnabledFunc(d)))
verb := "created"
if !isNew {
verb = "updated existing"
}
return fmt.Sprintf("%s finding id=%s severity=%s host=%s", verb, saved.ID, saved.Severity, saved.Host), nil
}),
})
r.MustRegister(capability.Capability{
ID: "findings.update",
Class: capability.ClassFindings,
+56 -23
View File
@@ -43,6 +43,10 @@ type Profile struct {
// without someone having decided that it should.
AllowsSends bool `json:"allowsSends"`
// AllowsCredentials is the recommended credential-visibility setting for the
// token. Only the operator profile sets it.
AllowsCredentials bool `json:"allowsCredentials"`
RateLimitPerMin int `json:"rateLimitPerMin"`
MaxConcurrent int `json:"maxConcurrent"`
}
@@ -52,6 +56,7 @@ type Profile struct {
// already offers that predicate as its "Read-only" preset, and a profile should be a
// deliberate set that does not silently absorb every future read capability.
var reconGrants = []string{
"instance.get",
"history.list",
"history.stats",
"sitemap.get",
@@ -64,7 +69,7 @@ var reconGrants = []string{
"http.read",
"http.search",
"http.diff",
"config.intercept.get",
"websocket.list",
}
// Profiles returns the built-in profiles, in the order the UI shows them.
@@ -83,9 +88,17 @@ func Profiles() []Profile {
{
ID: "tester",
Title: "Active tester",
Description: "Recon, plus resending edited requests and sending capped batches of variants " +
"through the proxy. Emits real traffic to targets, so it is held to the scope guard.",
Grants: append(slices.Clone(reconGrants), "http.resend", "http.batch"),
Description: "Recon, plus resending edited requests, sending capped batches of variants and " +
"running short fuzzing campaigns, holding a session across them. Emits real traffic to " +
"targets, so it is held to the scope guard.",
// The intercept reads live here rather than in reconGrants: they exist to
// explain a send that hung in the operator's queue, and this is the only
// profile that sends.
Grants: append(slices.Clone(reconGrants),
"http.resend", "http.batch", "context.get", "context.clear",
"fuzzer.start", "fuzzer.status", "fuzzer.results", "fuzzer.stop",
"findings.create", "notes.create",
"config.intercept.get", "config.intercept.list"),
RequireScope: true,
AllowsSends: true,
RateLimitPerMin: 120,
@@ -94,10 +107,11 @@ func Profiles() []Profile {
{
ID: "triage",
Title: "Triage analyst",
Description: "Recon, plus marking false positives, re-grading findings and writing notes. Sends " +
"nothing. For working through a detection backlog without touching the target.",
Description: "Recon, plus recording findings, marking false positives, re-grading and writing " +
"notes. Sends nothing. For working through a detection backlog without touching the target.",
Grants: append(slices.Clone(reconGrants),
"findings.update", "notes.create", "detect.rules.list", "detect.config.get"),
"findings.create", "findings.update", "notes.create",
"detect.rules.list", "detect.config.get"),
RequireScope: true,
RateLimitPerMin: 120,
MaxConcurrent: 2,
@@ -105,18 +119,25 @@ func Profiles() []Profile {
{
ID: "setup",
Title: "Engagement setup",
Description: "Recon, plus configuring the proxy for an engagement: scope include rules, Match & " +
"Replace, Custom Data, noise patterns and detection tuning. Sends nothing itself. Requires a " +
"token with scope enforcement off, since a token restricted by scope cannot edit scope — and " +
"note that adding a scope rule makes Joro intercept and record that host.",
Grants: append(slices.Clone(reconGrants),
"scope.addrule", "scope.enable",
Description: "Configures the proxy for an engagement: scope include rules, Match & Replace, Custom " +
"Data, noise patterns and detection tuning. Reads no captured traffic — it cannot list hosts, " +
"so name the ones to scope. Sends nothing. Requires a token with scope enforcement off, since a " +
"token restricted by scope cannot edit scope — and note that adding a scope rule makes Joro " +
"intercept and record that host. Findings are not visible from here: a rescan it triggers is " +
"read from the Detect tab.",
// Deliberately not built on reconGrants. This profile reads no captured
// traffic, and a read capability added to that base later must not
// silently reappear here.
Grants: []string{
"instance.get",
"scope.get", "scope.addrule", "scope.enable",
"config.intercept.get",
"config.replace.list", "config.replace.edit",
"config.customdata.list", "config.customdata.edit",
"config.noise.list", "config.noise.edit",
"detect.rules.list", "detect.rules.edit",
"detect.config.get", "detect.config.set", "detect.rescan",
"notes.create"),
},
RequireScope: false,
RateLimitPerMin: 60,
MaxConcurrent: 2,
@@ -124,14 +145,16 @@ func Profiles() []Profile {
{
ID: "operator",
Title: "Full operator",
Description: "Every capability Joro exposes: reads, sends, configuration and scope. Scope " +
"enforcement is off so the scope grants work, which means this token can reach any host. " +
"Issue it only for an agent you are supervising.",
Grants: nil, // filled from the registry by validateProfiles
RequireScope: false,
AllowsSends: true,
RateLimitPerMin: 120,
MaxConcurrent: 4,
Description: "Every capability except execution and C2: reads, sends, fuzzing, configuration and " +
"scope, plus the values of Authorization and Cookie headers. Scope enforcement is off so the " +
"scope grants work, which means this token can reach any host. Issue it only for an agent you " +
"are supervising. Execution and C2 are never bundled into a profile; grant them by hand.",
Grants: nil, // filled from the registry by validateProfiles
RequireScope: false,
AllowsSends: true,
AllowsCredentials: true,
RateLimitPerMin: 120,
MaxConcurrent: 4,
},
}
}
@@ -171,7 +194,14 @@ func validateProfiles(r *capability.Registry) {
seen[p.ID] = struct{}{}
if p.ID == operatorProfileID {
p.Grants = r.IDs()
// Every capability except the privileged ones. Without the filter, starting
// Joro with --automation-privileged would silently widen this profile to
// include execution and C2 for anyone who selected it.
for _, c := range r.All() {
if !c.Privileged {
p.Grants = append(p.Grants, c.ID)
}
}
}
if len(p.Grants) == 0 {
panic(fmt.Sprintf("capreg: profile %q grants nothing", p.ID))
@@ -192,6 +222,9 @@ func validateProfiles(r *capability.Registry) {
case c.SendsTraffic && !p.AllowsSends:
panic(fmt.Sprintf("capreg: profile %q grants %q, which sends traffic, but does not set "+
"AllowsSends. Either it should, or the grant does not belong in this profile.", p.ID, id))
case c.Privileged:
panic(fmt.Sprintf("capreg: profile %q grants privileged capability %q. Execution and C2 "+
"are granted per capability by hand, never bundled into a profile.", p.ID, id))
case c.UnrestrictedOnly && p.RequireScope:
panic(fmt.Sprintf("capreg: profile %q sets RequireScope and grants %q, which is refused to "+
"any token restricted by scope. That combination mints a token whose grant is denied on "+
+5
View File
@@ -35,6 +35,11 @@ type Config struct {
// no token file read. A deployment-posture switch, in the same family as
// --allowed-host and --disable-update-checks, not a way to invoke a feature.
NoAutomation bool
// AutomationPrivileged registers the execution and C2 capabilities. Off by
// default; even when on, no token profile grants one, so the operator must select
// each by hand. A launch flag rather than a Settings toggle so enabling it cannot
// happen without a restart.
AutomationPrivileged bool
}
// Default returns a Config populated with sensible defaults.
+6 -1
View File
@@ -691,7 +691,12 @@ func groupDim(rule *Rule, m *Message, hit AnalyzerHit) string {
}
// findingID builds the deterministic group hash.
func findingID(ruleID, host, dim string) string {
func findingID(ruleID, host, dim string) string { return FindingID(ruleID, host, dim) }
// FindingID is the dedupe identity: it contains no request ID, timestamp or seq, so
// rescanning reproduces byte-identical IDs. Exported so a caller outside the engine
// can mint a finding in the same identity space.
func FindingID(ruleID, host, dim string) string {
sum := sha256.Sum256([]byte(ruleID + "\x00" + host + "\x00" + dim))
return hex.EncodeToString(sum[:16])
}
+25
View File
@@ -48,6 +48,7 @@ type BatchArgs struct {
RatePerSec float64 `json:"ratePerSec"`
TimeoutMs int `json:"timeoutMs"`
TotalBudgetMs int `json:"totalBudgetMs"`
UseContext *bool `json:"useContext"`
}
type batchRow struct {
@@ -55,6 +56,7 @@ type batchRow struct {
fp Fingerprint
seq int
err string
ctx []string // cookie names the execution context supplied
}
// Batch sends a set of edited variants of one captured request and renders a
@@ -114,6 +116,12 @@ func Batch(ctx context.Context, d ResendDeps, args BatchArgs) (string, error) {
// Results land in a pre-sized slice indexed by variant, not appended. Order
// matters more here than in the fuzzer, because the client correlates rows
// back to the variants it wrote.
// The jar is read once up front and never written from a worker: variants run
// concurrently, so letting each one capture Set-Cookie would make which cookie
// wins depend on scheduling. A batch is a comparison, and every row must start
// from the same session state.
useContext := args.UseContext == nil || *args.UseContext
rows := make([]batchRow, len(args.Variants))
work := make(chan int, conc)
var wg sync.WaitGroup
@@ -147,6 +155,16 @@ func Batch(ctx context.Context, d ResendDeps, args BatchArgs) (string, error) {
}
raw = proxy.UpdateContentLength(raw)
if useContext {
var supplied []string
raw, supplied = d.Contexts.Apply(d.TokenID,
requestURL(scheme, host, raw), raw, editsTouchCookies(v.Edits))
if len(supplied) > 0 {
raw = proxy.UpdateContentLength(raw)
rows[idx].ctx = supplied
}
}
itemCtx, itemCancel := context.WithTimeout(runCtx, perItem)
res, err := SendViaProxy(itemCtx, raw, scheme, host, sendDeps)
itemCancel()
@@ -213,6 +231,13 @@ func renderBatch(args BatchArgs, rows []batchRow, elapsed time.Duration) string
}
out := t.String()
var supplied []string
for _, r := range rows {
supplied = mergeNames(supplied, r.ctx)
}
if note := contextNote(supplied); note != "" {
out += "\n" + note
}
if len(outliers) > 0 {
out += fmt.Sprintf("\noutliers: %s (shash differs from the majority)", strings.Join(outliers, " "))
}
+4 -7
View File
@@ -30,13 +30,10 @@ const (
maxDiffLineLen = 200
)
// sensitiveHeaders have their values suppressed in a diff. A diff is precisely
// where a live session token would otherwise be copied into a model's context, so
// these report name and presence only.
var sensitiveHeaders = map[string]bool{
"set-cookie": true, "cookie": true, "authorization": true,
"proxy-authorization": true, "x-api-key": true, "x-auth-token": true,
}
// A diff reports name and presence only for sensitiveHeaders (redact.go): a diff is
// precisely where a live session token would otherwise be copied into a model's
// context. That holds regardless of credential visibility, since a diff has no byte
// offsets to preserve and the changed-or-not signal is the whole finding.
// diffLoudHeaders are volatile-valued headers that a diff still reports.
//
+283
View File
@@ -0,0 +1,283 @@
package httptools
import (
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"slices"
"sort"
"strings"
"sync"
"time"
"golang.org/x/net/publicsuffix"
)
// maxJars bounds how many principals hold a live cookie jar at once. Well above
// any realistic token count; a backstop, not a workflow limit.
const maxJars = 32
// Contexts holds one cookie jar per automation principal, so a multi-request
// authenticated flow does not require the agent to copy cookies by hand — which it
// cannot do at all without credential visibility.
//
// Jars are in-memory only and are dropped on restart, on token rotation or
// deletion, and on a project switch.
type Contexts struct {
mu sync.Mutex
jars map[string]*jarEntry
}
type jarEntry struct {
jar *cookiejar.Jar
used time.Time
// hosts records the origins this jar has seen, because cookiejar has no
// enumeration API: listing or clearing requires a URL to ask about.
hosts map[string]struct{}
}
func NewContexts() *Contexts {
return &Contexts{jars: map[string]*jarEntry{}}
}
// ContextCookie is one entry in a jar, as reported by context.get.
type ContextCookie struct {
Host string
Name string
Value string
}
func (c *Contexts) entry(tokenID string, create bool) *jarEntry {
if c == nil || tokenID == "" {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.jars[tokenID]; ok {
e.used = time.Now()
return e
}
if !create {
return nil
}
if len(c.jars) >= maxJars {
c.evictOldestLocked()
}
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
if err != nil {
return nil
}
e := &jarEntry{jar: jar, used: time.Now(), hosts: map[string]struct{}{}}
c.jars[tokenID] = e
return e
}
func (c *Contexts) evictOldestLocked() {
var oldestID string
var oldest time.Time
for id, e := range c.jars {
if oldestID == "" || e.used.Before(oldest) {
oldestID, oldest = id, e.used
}
}
delete(c.jars, oldestID)
}
// Capture records Set-Cookie headers from a response.
func (c *Contexts) Capture(tokenID string, u *url.URL, respRaw []byte) {
if u == nil || len(respRaw) == 0 {
return
}
e := c.entry(tokenID, true)
if e == nil {
return
}
hdr, _, _ := splitRaw(respRaw)
_, h := parseHeaderBlock(hdr)
resp := http.Response{Header: h}
cookies := resp.Cookies()
if len(cookies) == 0 {
return
}
c.mu.Lock()
e.hosts[u.Host] = struct{}{}
c.mu.Unlock()
e.jar.SetCookies(u, cookies)
}
// Apply merges jar cookies into a raw request and reports the names it supplied.
//
// It never overrides: a cookie the request already carries wins, and if the caller
// edited the Cookie header at all the jar stays out entirely. Re-adding a session
// cookie to a request an agent deliberately stripped would manufacture a false
// negative, which is the worst outcome in an authorization test.
func (c *Contexts) Apply(tokenID string, u *url.URL, raw []byte, editedCookies bool) ([]byte, []string) {
if u == nil || editedCookies || len(raw) == 0 {
return raw, nil
}
e := c.entry(tokenID, false)
if e == nil {
return raw, nil
}
jarCookies := e.jar.Cookies(u)
if len(jarCookies) == 0 {
return raw, nil
}
present := map[string]bool{}
hdrRaw, _, _ := splitRaw(raw)
for _, line := range splitHeaderLines(hdrRaw) {
if !strings.EqualFold(headerNameOf(line), "cookie") {
continue
}
for _, pair := range strings.Split(headerValueOf(line), ";") {
if n, _, ok := strings.Cut(strings.TrimSpace(pair), "="); ok {
present[strings.TrimSpace(n)] = true
}
}
}
var add []string
var names []string
for _, ck := range jarCookies {
if present[ck.Name] {
continue
}
add = append(add, ck.Name+"="+ck.Value)
names = append(names, ck.Name)
}
if len(add) == 0 {
return raw, nil
}
joined := strings.Join(add, "; ")
out, err := ApplyEdits(raw, []Edit{{Op: "addHeader", Name: "Cookie", Value: joined}})
if err != nil {
return raw, nil
}
return out, names
}
// List returns the jar's contents. Values are included only when the caller is
// permitted to see them.
func (c *Contexts) List(tokenID string, withValues bool) []ContextCookie {
e := c.entry(tokenID, false)
if e == nil {
return nil
}
c.mu.Lock()
hosts := make([]string, 0, len(e.hosts))
for h := range e.hosts {
hosts = append(hosts, h)
}
c.mu.Unlock()
sort.Strings(hosts)
var out []ContextCookie
for _, h := range hosts {
for _, ck := range e.jar.Cookies(&url.URL{Scheme: "https", Host: h, Path: "/"}) {
entry := ContextCookie{Host: h, Name: ck.Name}
if withValues {
entry.Value = ck.Value
}
out = append(out, entry)
}
}
return out
}
// Clear drops the whole jar, or expires every cookie for one host. It reports how
// many cookies it removed.
func (c *Contexts) Clear(tokenID, host string) int {
if c == nil || tokenID == "" {
return 0
}
host = strings.TrimSpace(strings.ToLower(host))
if host == "" {
n := len(c.List(tokenID, false))
c.mu.Lock()
delete(c.jars, tokenID)
c.mu.Unlock()
return n
}
e := c.entry(tokenID, false)
if e == nil {
return 0
}
u := &url.URL{Scheme: "https", Host: host, Path: "/"}
existing := e.jar.Cookies(u)
if len(existing) == 0 {
return 0
}
expired := make([]*http.Cookie, 0, len(existing))
for _, ck := range existing {
expired = append(expired, &http.Cookie{Name: ck.Name, Value: "", MaxAge: -1, Path: "/"})
}
e.jar.SetCookies(u, expired)
c.mu.Lock()
delete(e.hosts, host)
c.mu.Unlock()
return len(existing)
}
// Reset drops one principal's jar. ResetAll drops every jar, which is what a
// project switch does: a session from a previous engagement must not be replayed
// into a new one.
func (c *Contexts) Reset(tokenID string) { c.Clear(tokenID, "") }
func (c *Contexts) ResetAll() {
if c == nil {
return
}
c.mu.Lock()
clear(c.jars)
c.mu.Unlock()
}
// requestURL builds the URL a send will dial, for jar matching. The path matters:
// cookies are path-scoped.
func requestURL(scheme, host string, raw []byte) *url.URL {
scheme = strings.ToLower(strings.TrimSpace(scheme))
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if strings.TrimSpace(host) == "" {
return nil
}
path := "/"
if _, target, _, ok := requestLine(firstLine(raw)); ok {
if p, _ := splitTarget(target); strings.HasPrefix(p, "/") {
path = p
}
}
return &url.URL{Scheme: scheme, Host: host, Path: path}
}
// editsTouchCookies reports whether the caller controlled the Cookie header
// itself, in which case the jar must not second-guess it.
func editsTouchCookies(edits []Edit) bool {
return slices.ContainsFunc(edits, func(e Edit) bool {
switch e.Op {
case "setHeader", "addHeader", "removeHeader":
return strings.EqualFold(strings.TrimSpace(e.Name), "cookie")
}
return false
})
}
func headerValueOf(line string) string {
_, v, _ := strings.Cut(line, ":")
return strings.TrimSpace(v)
}
// contextNote renders the "the jar supplied these" line appended to a send result.
func contextNote(names []string) string {
if len(names) == 0 {
return ""
}
return fmt.Sprintf("context: supplied cookie %s (not in your request; use useContext=false to send without)",
strings.Join(names, ", "))
}
+7
View File
@@ -42,6 +42,9 @@ type ReadResult struct {
Truncated bool `json:"truncated"`
Decoded string `json:"decoded,omitempty"`
Text string `json:"text"`
// Redacted names the headers whose values were masked, set by the caller.
Redacted []string `json:"redacted,omitempty"`
}
// ReadRange extracts a byte window from a captured request or response.
@@ -159,6 +162,10 @@ func (r *ReadResult) Render() string {
fmt.Fprintf(&b, " decoded=%s", r.Decoded)
}
b.WriteByte('\n')
if note := RedactionNote(r.Redacted); note != "" {
b.WriteString(note)
b.WriteByte('\n')
}
b.WriteString(r.Text)
if r.Truncated {
// Truncation must always name the way to get the rest, or the client
+124
View File
@@ -0,0 +1,124 @@
package httptools
import (
"bytes"
"slices"
"strings"
)
// sensitiveHeaders have their values withheld from automation clients that were
// not granted credential visibility. Shared by MaskHeaders and http.diff.
var sensitiveHeaders = map[string]bool{
"authorization": true, "proxy-authorization": true,
"cookie": true, "set-cookie": true,
"x-api-key": true, "x-auth-token": true,
"x-csrf-token": true, "x-xsrf-token": true,
}
// maskByte replaces each byte of a withheld header value.
const maskByte = '*'
// MaskHeaders overwrites the values of sensitive headers with '*', leaving header
// names, framing and every byte offset unchanged, and returns the names it masked.
//
// Masking is length-preserving because http.read reports offsets and totalLength
// against the same coordinates the History Raw tab uses; a shorter placeholder
// would shift every subsequent offset and desynchronize http.read from http.search.
func MaskHeaders(raw []byte) (masked []byte, names []string) {
if len(raw) == 0 {
return raw, nil
}
hdr, _, _ := splitRaw(raw)
if len(hdr) == 0 {
return raw, nil
}
out := bytes.Clone(raw)
seen := map[string]bool{}
folding := false
for i := 0; i < len(hdr); {
end := bytes.IndexByte(hdr[i:], '\n')
if end < 0 {
end = len(hdr)
} else {
end += i
}
line := out[i:end]
if n := len(line); n > 0 && line[n-1] == '\r' {
line = line[:n-1]
}
switch {
case len(line) == 0:
folding = false
case line[0] == ' ' || line[0] == '\t':
// Obsolete line folding: the continuation belongs to the previous header.
if folding {
maskRange(line)
}
default:
folding = false
if c := bytes.IndexByte(line, ':'); c > 0 {
name := strings.ToLower(strings.TrimSpace(string(line[:c])))
if sensitiveHeaders[name] {
maskRange(line[c+1:])
folding = true
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
}
i = end + 1
}
if len(names) == 0 {
return raw, nil
}
slices.Sort(names)
return out, names
}
// maskRange overwrites v in place, preserving leading whitespace so the header
// still parses.
func maskRange(v []byte) {
for i := range v {
if i == 0 && (v[i] == ' ' || v[i] == '\t') {
continue
}
v[i] = maskByte
}
}
// RedactionNote is the line appended to any output whose bytes were masked. Without
// it a masked Authorization header reads as an absent one, which is how an agent
// reports an authenticated endpoint as unauthenticated.
func RedactionNote(names []string) string {
if len(names) == 0 {
return ""
}
return "redacted: " + strings.Join(names, ", ") +
" (values withheld; this token does not have credential visibility)"
}
// MaskPair masks both halves of a captured exchange, returning the union of the
// names masked in either.
func MaskPair(reqRaw, respRaw []byte) (req, resp []byte, names []string) {
req, a := MaskHeaders(reqRaw)
resp, b := MaskHeaders(respRaw)
return req, resp, mergeNames(a, b)
}
func mergeNames(a, b []string) []string {
if len(a) == 0 {
return b
}
if len(b) == 0 {
return a
}
out := append(slices.Clone(a), b...)
slices.Sort(out)
return slices.Compact(out)
}
+23 -2
View File
@@ -29,12 +29,18 @@ type ResendArgs struct {
Host string `json:"host"`
UpdateContentLength *bool `json:"updateContentLength"`
TimeoutMs int `json:"timeoutMs"`
UseContext *bool `json:"useContext"`
}
// ResendDeps is what a resend needs from the host process.
type ResendDeps struct {
Send SendDeps
Store *proxy.Store
// Contexts is the per-principal cookie jar; TokenID selects this caller's.
// Both may be zero, in which case sends are stateless.
Contexts *Contexts
TokenID string
}
// TargetOf resolves the scheme and host a resend will dial, without sending.
@@ -107,13 +113,25 @@ func Resend(ctx context.Context, d ResendDeps, args ResendArgs) (string, error)
sendCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
u := requestURL(scheme, host, raw)
var supplied []string
if args.UseContext == nil || *args.UseContext {
raw, supplied = d.Contexts.Apply(d.TokenID, u, raw, editsTouchCookies(args.Edits))
if len(supplied) > 0 {
raw = proxy.UpdateContentLength(raw)
}
}
res, err := SendViaProxy(sendCtx, raw, scheme, host, d.Send)
if err != nil {
return "", annotateSendErr(err, timeout)
}
if args.UseContext == nil || *args.UseContext {
d.Contexts.Capture(d.TokenID, u, res.RespRaw)
}
fp := fingerprintResponse(res.Seq, res.RespRaw, res.Duration.Milliseconds(), false)
return renderResend(args, res, fp), nil
return renderResend(args, res, fp, supplied), nil
}
// annotateSendErr explains the failure mode a client is most likely to hit and
@@ -129,7 +147,7 @@ func annotateSendErr(err error, timeout time.Duration) error {
return err
}
func renderResend(args ResendArgs, res *ProxySendResult, fp Fingerprint) string {
func renderResend(args ResendArgs, res *ProxySendResult, fp Fingerprint, supplied []string) string {
var b strings.Builder
seqLabel := "seq " + itoa(res.Seq)
@@ -152,6 +170,9 @@ func renderResend(args ResendArgs, res *ProxySendResult, fp Fingerprint) string
}
b.WriteString(strings.Join(bits, " ") + "\n")
}
if note := contextNote(supplied); note != "" {
b.WriteString(note + "\n")
}
if res.Seq == 0 {
fmt.Fprintf(&b, "note: %s, so this response is not addressable by seq\n", res.SeqNote)
+28 -6
View File
@@ -54,6 +54,11 @@ type SearchArgs struct {
type SearchDeps struct {
Store *proxy.Store
Scope *proxy.Scope
// MaskCredentials masks sensitive header values before the pattern is applied,
// so a match cannot be reported from inside a value the caller may not see. It
// lives here rather than in SearchArgs because it is the host's decision.
MaskCredentials bool
}
// SearchCorpus greps captured traffic and returns match offsets with context.
@@ -121,6 +126,7 @@ func SearchCorpus(d SearchDeps, args SearchArgs) (string, error) {
t.empty = "(no matches)"
hits, scanned, withHits := 0, 0, 0
var redacted []string
for _, item := range items {
scanned++
before := hits
@@ -129,7 +135,8 @@ func SearchCorpus(d SearchDeps, args SearchArgs) (string, error) {
if len(raw) == 0 {
continue
}
body := searchable(raw, args.Deep)
body, masked := searchable(raw, args.Deep, d.MaskCredentials)
redacted = mergeNames(redacted, masked)
for _, loc := range matcher.find(body, perReq-(hits-before)) {
t.add(strconv.Itoa(item.Seq), pw.name, strconv.Itoa(loc),
contextAround(body, loc, matcher.width(body, loc), ctxBytes))
@@ -162,6 +169,9 @@ func SearchCorpus(d SearchDeps, args SearchArgs) (string, error) {
if !args.Deep {
t.note("note: matched against raw captured bytes, so a compressed response will not match a plaintext pattern (use deep=true)")
}
if note := RedactionNote(redacted); note != "" {
t.note(note)
}
return t.String(), nil
}
@@ -175,12 +185,14 @@ func searchSingle(d SearchDeps, args SearchArgs, m *matcher, ctxBytes int) (stri
t := newTable("part", "off", "context")
t.empty = "(no matches)"
hits := 0
var redacted []string
for _, pw := range partsFor(args.Part) {
raw := pw.pick(item)
if len(raw) == 0 {
continue
}
body := searchable(raw, args.Deep)
body, masked := searchable(raw, args.Deep, d.MaskCredentials)
redacted = mergeNames(redacted, masked)
for _, loc := range m.find(body, limit-hits) {
t.add(pw.name, strconv.Itoa(loc), contextAround(body, loc, m.width(body, loc), ctxBytes))
hits++
@@ -190,6 +202,9 @@ func searchSingle(d SearchDeps, args SearchArgs, m *matcher, ctxBytes int) (stri
}
}
t.note(fmt.Sprintf("q=%q mode=single seq=%d hits=%d", args.Pattern, args.Ref, hits))
if note := RedactionNote(redacted); note != "" {
t.note(note)
}
return t.String(), nil
}
@@ -198,18 +213,25 @@ func searchSingle(d SearchDeps, args SearchArgs, m *matcher, ctxBytes int) (stri
// so reported offsets still point into a document the client can ask to read.
// Those offsets are into the decoded document, not the wire bytes — which is why
// deep mode is opt-in rather than the default.
func searchable(raw []byte, deep bool) []byte {
//
// Masking runs first and is length-preserving, so a pattern cannot match inside a
// withheld header value and offsets are unchanged either way.
func searchable(raw []byte, deep, mask bool) ([]byte, []string) {
var names []string
if mask {
raw, names = MaskHeaders(raw)
}
if !deep {
return raw
return raw, names
}
m := parseMessage(raw, true)
if m.Decoded == "" {
return raw
return raw, names
}
out := make([]byte, 0, len(m.HdrRaw)+4+len(m.Body))
out = append(out, m.HdrRaw...)
out = append(out, '\r', '\n', '\r', '\n')
return append(out, m.Body...)
return append(out, m.Body...), names
}
type partPicker struct {
+4
View File
@@ -74,6 +74,10 @@ answer the question.
http_resend and http_batch send real traffic to a real target through the proxy, so the result
appears in the operator's history. They are refused for hosts outside the configured scope.
Unless this token has credential visibility, the values of Authorization, Cookie, Set-Cookie and
similar headers are overwritten with '*' wherever bytes are returned. A masked header is present,
not absent — do not conclude an endpoint is unauthenticated from one.
Some tools change Joro itself rather than reading it, and a human operator is working in the
same instance: scope rules decide which hosts get intercepted at all, config_* rewrites or
appends to every in-scope request, and detect_* changes what is reported across the whole
+2 -1
View File
@@ -31,7 +31,7 @@ import (
"github.com/BishopFox/joro/internal/xsshunter"
)
var version = "v1.10.0"
var version = "v1.11.0"
var commit = "dev" // injected via -ldflags at build time
func main() {
@@ -61,6 +61,7 @@ func main() {
flag.BoolVar(&cfg.TeamServer, "teamserver", false, "Enable team server mode (requires --listener)")
flag.BoolVar(&cfg.DisableUpdateChecks, "disable-update-checks", false, "Disable automatic update checks at startup and in the background (can also be toggled in Settings)")
flag.BoolVar(&cfg.NoAutomation, "no-automation", false, "Disable the automation API and MCP listener entirely (no routes, no token file, no second port)")
flag.BoolVar(&cfg.AutomationPrivileged, "automation-privileged", false, "Expose web shell execution and Sliver/Mythic C2 as automation capabilities (off by default; still requires an explicit per-capability grant)")
buildPlugin := flag.String("build-plugin", "", "Build a plugin from the given directory and exit")
installPlugin := flag.Bool("install", false, "Copy built plugin to ~/.joro/plugins/ (use with --build-plugin)")
+13 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { ChevronDown, ChevronRight, Eye, KeyRound } from 'lucide-react'
import { api, type AuditEntry } from '../lib/api'
import { useToastStore } from '../stores/toastStore'
@@ -89,6 +89,18 @@ export default function AutomationActivity() {
<span className="text-content-muted shrink-0">{new Date(e.at).toLocaleTimeString()}</span>
<span className="shrink-0 w-24 truncate">{e.tokenName}</span>
<span className="shrink-0 w-32 truncate">{e.capability}</span>
<span className="shrink-0 w-6 inline-flex gap-0.5">
{e.privileged && (
<span className="inline-flex" title="Command execution or C2">
<KeyRound size={10} strokeWidth={2} className="text-semantic-error" aria-hidden="true" />
</span>
)}
{e.credentials && (
<span className="inline-flex" title="Could return unmasked credential headers">
<Eye size={10} strokeWidth={2} className="text-semantic-warning" aria-hidden="true" />
</span>
)}
</span>
<span className="shrink-0 w-14">{e.code || e.result}</span>
<span className="text-content-muted truncate">
{/* A mutation has no target, so without `change` these rows would
@@ -208,6 +208,14 @@ export default function AutomationSettings() {
+{t.hostAllow.length} host
</span>
)}
{t.allowCredentials && (
<span
className="block text-[10px] text-semantic-warning"
title="Authorization, Cookie and similar header values are returned in full to this token."
>
credentials visible
</span>
)}
{inertGrants(t).length > 0 && (
<span
className="block text-[10px] text-semantic-warning"
+26 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { AlertTriangle, Check, Unlock } from 'lucide-react'
import { AlertTriangle, Check, KeyRound, Unlock } from 'lucide-react'
import type { AutomationProfile, AutomationToken, AutomationTokenInput, Capability } from '../lib/api'
import GrantPicker from './GrantPicker'
@@ -28,6 +28,7 @@ export default function AutomationTokenModal({
const [grants, setGrants] = useState<string[]>(token?.grants ?? [])
const [requireScope, setRequireScope] = useState(token?.requireScope ?? true)
const [hostAllow, setHostAllow] = useState((token?.hostAllow ?? []).join(', '))
const [allowCredentials, setAllowCredentials] = useState(token?.allowCredentials ?? false)
const [rateLimit, setRateLimit] = useState(token?.rateLimitPerMin ?? 60)
const [maxConcurrent, setMaxConcurrent] = useState(token?.maxConcurrent ?? 2)
const [expiresInDays, setExpiresInDays] = useState(0)
@@ -41,6 +42,7 @@ export default function AutomationTokenModal({
// requireScope or a host whitelist is not rejected on save — it fails closed at call
// time — but silently handing over a grant that can never fire is worse than saying so.
const unrestrictedOnly = capabilities.filter((c) => c.unrestrictedOnly && grants.includes(c.id))
const privileged = capabilities.filter((c) => c.privileged && grants.includes(c.id))
const inertGrants = unrestrictedOnly.length > 0 && (requireScope || hostPatterns.length > 0)
const canEditScope = unrestrictedOnly.length > 0 && !inertGrants
@@ -49,6 +51,7 @@ export default function AutomationTokenModal({
// token shape the profile expects, including turning requireScope off for a
// profile whose scope grants would otherwise be inert.
setRequireScope(p.requireScope)
setAllowCredentials(p.allowsCredentials)
setRateLimit(p.rateLimitPerMin)
setMaxConcurrent(p.maxConcurrent)
}
@@ -62,6 +65,7 @@ export default function AutomationTokenModal({
grants,
requireScope,
hostAllow: hostPatterns,
allowCredentials,
rateLimitPerMin: rateLimit,
maxConcurrent,
}
@@ -145,6 +149,16 @@ export default function AutomationTokenModal({
)}
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer pt-1">
<input type="checkbox" checked={allowCredentials} onChange={(e) => setAllowCredentials(e.target.checked)} />
<span className="text-xs">Show credential header values</span>
</label>
<p className="text-[10px] text-content-muted leading-snug">
{allowCredentials
? 'Authorization, Cookie, Set-Cookie and similar values are returned in full. Every session token in captured traffic is readable by this token.'
: 'Those values are masked wherever bytes are returned. The header is still reported as present, and the agent can stay authenticated through its own session cookies without seeing them.'}
</p>
</div>
{sendsTraffic && (
@@ -173,6 +187,17 @@ export default function AutomationTokenModal({
</p>
)}
{privileged.length > 0 && (
<p className="text-[11px] text-semantic-error leading-snug inline-flex items-start gap-1.5">
<KeyRound size={12} strokeWidth={2} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>
This token can run commands: {privileged.map((c) => c.toolName).join(', ')}. Scope and the host whitelist
do not bound the C2 capabilities — they describe web targets, not a team server — so this grant is the
only limit on what they reach.
</span>
</p>
)}
{inertGrants && (
<p className="text-[11px] text-semantic-warning leading-snug">
{unrestrictedOnly.map((c) => c.toolName).join(', ')} will be refused on every call: a token restricted by
+23 -7
View File
@@ -1,11 +1,18 @@
import { useEffect } from 'react'
import { useEffect, type ReactNode } from 'react'
type Props = {
title?: string
message: string
/** Extra detail below the message, for a confirmation that needs more than a line. */
body?: ReactNode
confirmLabel?: string
cancelLabel?: string
danger?: boolean
/** Must sit above the modal that opened it. Defaults to the standalone tier. */
zIndexClass?: string
/** Requires a deliberate click: the confirm button is not focused and Enter does
* not confirm. Escape still cancels. */
deliberate?: boolean
onConfirm: () => void
onClose: () => void
}
@@ -13,31 +20,40 @@ type Props = {
export default function ConfirmModal({
title = 'Confirm',
message,
body,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
danger = true,
zIndexClass = 'z-50',
deliberate = false,
onConfirm,
onClose,
}: Props) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Enter') onConfirm()
if (e.key === 'Enter' && !deliberate) onConfirm()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, onConfirm])
}, [onClose, onConfirm, deliberate])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onMouseDown={onClose}>
<div
className={`fixed inset-0 ${zIndexClass} flex items-center justify-center bg-black/60 p-6`}
onMouseDown={onClose}
>
<div
className="flex flex-col w-full max-w-sm bg-surface-card border border-border rounded shadow-lg overflow-hidden"
className={`flex flex-col w-full ${body ? 'max-w-md' : 'max-w-sm'} bg-surface-card border border-border rounded shadow-lg overflow-hidden`}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="px-4 py-3 border-b border-border">
<span className="text-xs font-semibold text-content-primary uppercase tracking-wide">{title}</span>
</div>
<p className="px-4 py-4 text-sm text-content-primary">{message}</p>
<div className="px-4 py-4 space-y-3">
<p className="text-sm text-content-primary">{message}</p>
{body}
</div>
<div className="flex justify-end gap-2 px-4 py-3 border-t border-border">
<button
onClick={onClose}
@@ -47,7 +63,7 @@ export default function ConfirmModal({
</button>
<button
onClick={onConfirm}
autoFocus
autoFocus={!deliberate}
className={`px-3 py-1.5 rounded-sm text-xs font-semibold ${
danger
? 'bg-semantic-error-bg hover:bg-semantic-error-hover text-content-primary'
+242 -127
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { AlertTriangle, Pencil, Unlock } from 'lucide-react'
import { useMemo, useState } from 'react'
import { AlertTriangle, KeyRound, Pencil, Unlock } from 'lucide-react'
import type { AutomationProfile, Capability } from '../lib/api'
import ConfirmModal from './ConfirmModal'
type Props = {
capabilities: Capability[]
@@ -21,14 +22,20 @@ type Props = {
}
const CLASS_LABELS: Record<string, string> = {
instance: 'Instance',
history: 'History',
sitemap: 'Site map',
scope: 'Scope',
findings: 'Detection findings',
notes: 'Notes',
http: 'HTTP tools',
websocket: 'WebSocket traffic',
fuzzer: 'Fuzzer',
context: 'Session context',
config: 'Proxy configuration',
detect: 'Detection rules & scanning',
exec: 'Command execution',
c2: 'C2 servers',
}
/**
@@ -72,11 +79,30 @@ export default function GrantPicker({
const sel = new Set(selected)
const isNew = new Set(highlight)
// Acknowledgement of the privileged warning, and the change waiting on it. Both
// live here so they reset when the containing token modal closes: a grant that
// runs commands should be confirmed once per editing session, not once ever.
const [ackPrivileged, setAckPrivileged] = useState(false)
const [pending, setPending] = useState<{ next: string[]; caps: Capability[] } | null>(null)
// The single write path. Every selection change goes through here, so a privileged
// capability cannot be added by a route that skips the confirmation. The gate keys
// on additions, so unticking one never prompts and neither does opening a token
// that already holds one.
const commit = (next: Set<string>) => {
const added = capabilities.filter((c) => c.privileged && next.has(c.id) && !sel.has(c.id))
if (added.length > 0 && !ackPrivileged) {
setPending({ next: [...next].sort(), caps: added })
return
}
onChange([...next].sort())
}
const toggle = (id: string) => {
const next = new Set(sel)
if (next.has(id)) next.delete(id)
else next.add(id)
onChange([...next].sort())
commit(next)
}
const toggleClass = (caps: Capability[], on: boolean) => {
@@ -85,147 +111,236 @@ export default function GrantPicker({
if (on) next.add(c.id)
else next.delete(c.id)
}
onChange([...next].sort())
commit(next)
}
const preset = (fn: (c: Capability) => boolean) =>
onChange(capabilities.filter(fn).map((c) => c.id).sort())
commit(new Set(capabilities.filter(fn).map((c) => c.id)))
const applyProfile = (p: AutomationProfile) => {
// Expand to a concrete list. Intersected with what this build actually
// registers, so a profile naming something unknown selects nothing rather than
// putting an unknown ID in the grant list.
const known = new Set(capabilities.map((c) => c.id))
onChange(p.grants.filter((id) => known.has(id)).sort())
commit(new Set(p.grants.filter((id) => known.has(id))))
onApplyProfile?.(p)
}
return (
<div className="space-y-3">
{profiles.length > 0 && (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-content-muted">Profiles:</span>
{profiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => applyProfile(p)}
title={`${p.description} (${p.grants.length} capabilities)`}
className="px-2 py-0.5 text-[11px] rounded border border-border bg-surface-input hover:bg-surface-hover"
>
{p.title}
{p.allowsSends && (
<AlertTriangle
size={10}
strokeWidth={2}
className="inline ml-1 -mt-0.5 text-semantic-warning"
aria-hidden="true"
/>
)}
</button>
))}
<>
<div className="space-y-3">
{profiles.length > 0 && (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-content-muted">Profiles:</span>
{profiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => applyProfile(p)}
title={`${p.description} (${p.grants.length} capabilities)`}
className="px-2 py-0.5 text-[11px] rounded border border-border bg-surface-input hover:bg-surface-hover"
>
{p.title}
{p.allowsSends && (
<AlertTriangle
size={10}
strokeWidth={2}
className="inline ml-1 -mt-0.5 text-semantic-warning"
aria-hidden="true"
/>
)}
</button>
))}
</div>
<p className="text-[10px] text-content-muted leading-snug">
A profile fills in a grant list you can then edit. It is not remembered on the token, so a
capability added to a profile in a later release is never granted retroactively.
</p>
</div>
<p className="text-[10px] text-content-muted leading-snug">
A profile fills in a grant list you can then edit. It is not remembered on the token, so a
capability added to a profile in a later release is never granted retroactively.
</p>
)}
<div className="flex items-center gap-2 text-[11px]">
<span className="text-content-muted">Presets:</span>
<button
type="button"
// Privileged is excluded explicitly: the C2 read capabilities are neither
// mutating nor send-capable, so the plain predicate would select them.
onClick={() => preset((c) => !c.privileged && !c.mutating && !c.sendsTraffic)}
className="text-accent-secondary hover:underline"
>
Read-only
</button>
<button
type="button"
onClick={() => preset((c) => !c.privileged)}
className="text-accent-secondary hover:underline"
title="Every capability except command execution and C2, which are selected individually"
>
Everything
</button>
<button type="button" onClick={() => onChange([])} className="text-content-muted hover:underline">
Clear
</button>
<span className="ml-auto text-content-muted">{sel.size} selected</span>
</div>
<div className="max-h-72 overflow-y-auto border border-border rounded divide-y divide-border-subtle">
{byClass.map(([cls, caps]) => {
const all = caps.every((c) => sel.has(c.id))
const some = !all && caps.some((c) => sel.has(c.id))
const sends = caps.some((c) => c.sendsTraffic)
const writes = !sends && caps.some((c) => c.mutating)
const priv = caps.some((c) => c.privileged)
return (
<div key={cls} className="p-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={all}
ref={(el) => {
if (el) el.indeterminate = some
}}
onChange={() => toggleClass(caps, !all)}
/>
<span className="text-xs font-semibold">{CLASS_LABELS[cls] ?? cls}</span>
{sends && (
<span className="text-[10px] text-semantic-warning inline-flex items-center gap-1">
<AlertTriangle size={11} strokeWidth={2} aria-hidden="true" />
these emit traffic to targets
</span>
)}
{writes && (
<span className="text-[10px] text-semantic-special inline-flex items-center gap-1">
<Pencil size={11} strokeWidth={2} aria-hidden="true" />
some of these change your proxy
</span>
)}
{priv && (
<span className="text-[10px] text-semantic-error inline-flex items-center gap-1">
<KeyRound size={11} strokeWidth={2} aria-hidden="true" />
these run commands
</span>
)}
</label>
<div className="mt-1 ml-5 space-y-1">
{caps.map((c) => (
<label key={c.id} className="flex items-start gap-2 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5"
checked={sel.has(c.id)}
onChange={() => toggle(c.id)}
/>
<span className="min-w-0">
<span className="text-xs flex items-center gap-1.5">
<code className="font-mono text-[11px]">{c.toolName}</code>
{c.sendsTraffic && (
<AlertTriangle size={11} strokeWidth={2} className="text-semantic-warning shrink-0" aria-hidden="true" />
)}
{c.mutating && !c.sendsTraffic && (
<span className="shrink-0 inline-flex" title="Changes Joro’s own configuration">
<Pencil size={11} strokeWidth={2} className="text-semantic-special" aria-hidden="true" />
</span>
)}
{c.privileged && (
<span
className="shrink-0 inline-flex"
title="Runs commands. Not included in any profile; grant it deliberately."
>
<KeyRound size={11} strokeWidth={2} className="text-semantic-error" aria-hidden="true" />
</span>
)}
{c.unrestrictedOnly && (
<span
className="shrink-0 inline-flex"
title="Refused unless this token has scope enforcement off and no host whitelist"
>
<Unlock size={11} strokeWidth={2} className="text-semantic-error" aria-hidden="true" />
</span>
)}
{isNew.has(c.id) && <span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" title="New since this token was last reviewed" />}
</span>
<span className="block text-[10px] text-content-muted leading-snug">{c.title}</span>
</span>
</label>
))}
</div>
</div>
)
})}
</div>
</div>
{pending && (
<ConfirmModal
title="Privileged grant"
message={privilegedMessage(pending.caps)}
body={<PrivilegedWarning caps={pending.caps} />}
confirmLabel={pending.caps.length > 1 ? 'Grant all' : 'Grant'}
// Above the token modal that contains this picker, which is z-[60].
zIndexClass="z-[70]"
deliberate
onConfirm={() => {
setAckPrivileged(true)
onChange(pending.next)
setPending(null)
}}
onClose={() => setPending(null)}
/>
)}
</>
)
}
function privilegedMessage(caps: Capability[]): string {
if (caps.length === 1) {
return `${caps[0].toolName} lets this token run commands.`
}
return `These ${caps.length} capabilities let this token run commands.`
}
/** The detail behind a privileged grant: what holds the authority, and what does and
* does not bound it. Containment differs by family, so the middle paragraph is
* derived from what is actually being granted. */
function PrivilegedWarning({ caps }: { caps: Capability[] }) {
const hasExec = caps.some((c) => c.class === 'exec')
const hasC2 = caps.some((c) => c.class === 'c2')
return (
<div className="space-y-2 text-[11px] text-content-secondary leading-snug">
<ul className="space-y-0.5">
{caps.map((c) => (
<li key={c.id} className="flex items-start gap-1.5">
<KeyRound size={11} strokeWidth={2} className="text-semantic-error mt-0.5 shrink-0" aria-hidden="true" />
<span>
<code className="font-mono text-[11px] text-content-primary">{c.toolName}</code>
<span className="block text-content-muted">{c.title}</span>
</span>
</li>
))}
</ul>
<p>
The token’s secret is a bearer credential: anything holding it can invoke this. Joro’s tokens scope and
record what an agent does — they are not a sandbox on the machine.
</p>
{hasExec && (
<p>
<code className="font-mono">exec_webshell</code> is held to this token’s scope and host whitelist, so it
cannot reach a host outside the engagement.
</p>
)}
{hasC2 && (
<p className="text-semantic-warning">
Scope and the host whitelist do <strong>not</strong> bound the C2 capabilities — they describe web
targets, not a C2 server. This grant is the only limit on what they reach.
</p>
)}
<div className="flex items-center gap-2 text-[11px]">
<span className="text-content-muted">Presets:</span>
<button
type="button"
onClick={() => preset((c) => !c.mutating && !c.sendsTraffic)}
className="text-accent-secondary hover:underline"
>
Read-only
</button>
<button
type="button"
onClick={() => preset(() => true)}
className="text-accent-secondary hover:underline"
>
Everything
</button>
<button type="button" onClick={() => onChange([])} className="text-content-muted hover:underline">
Clear
</button>
<span className="ml-auto text-content-muted">{sel.size} selected</span>
</div>
<div className="max-h-72 overflow-y-auto border border-border rounded divide-y divide-border-subtle">
{byClass.map(([cls, caps]) => {
const all = caps.every((c) => sel.has(c.id))
const some = !all && caps.some((c) => sel.has(c.id))
const sends = caps.some((c) => c.sendsTraffic)
const writes = !sends && caps.some((c) => c.mutating)
return (
<div key={cls} className="p-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={all}
ref={(el) => {
if (el) el.indeterminate = some
}}
onChange={() => toggleClass(caps, !all)}
/>
<span className="text-xs font-semibold">{CLASS_LABELS[cls] ?? cls}</span>
{sends && (
<span className="text-[10px] text-semantic-warning inline-flex items-center gap-1">
<AlertTriangle size={11} strokeWidth={2} aria-hidden="true" />
these emit traffic to targets
</span>
)}
{writes && (
<span className="text-[10px] text-semantic-special inline-flex items-center gap-1">
<Pencil size={11} strokeWidth={2} aria-hidden="true" />
some of these change your proxy
</span>
)}
</label>
<div className="mt-1 ml-5 space-y-1">
{caps.map((c) => (
<label key={c.id} className="flex items-start gap-2 cursor-pointer group">
<input
type="checkbox"
className="mt-0.5"
checked={sel.has(c.id)}
onChange={() => toggle(c.id)}
/>
<span className="min-w-0">
<span className="text-xs flex items-center gap-1.5">
<code className="font-mono text-[11px]">{c.toolName}</code>
{c.sendsTraffic && (
<AlertTriangle size={11} strokeWidth={2} className="text-semantic-warning shrink-0" aria-hidden="true" />
)}
{c.mutating && !c.sendsTraffic && (
<span className="shrink-0 inline-flex" title="Changes Joro’s own configuration">
<Pencil size={11} strokeWidth={2} className="text-semantic-special" aria-hidden="true" />
</span>
)}
{c.unrestrictedOnly && (
<span
className="shrink-0 inline-flex"
title="Refused unless this token has scope enforcement off and no host whitelist"
>
<Unlock size={11} strokeWidth={2} className="text-semantic-error" aria-hidden="true" />
</span>
)}
{isNew.has(c.id) && <span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" title="New since this token was last reviewed" />}
</span>
<span className="block text-[10px] text-content-muted leading-snug">{c.title}</span>
</span>
</label>
))}
</div>
</div>
)
})}
</div>
<p className="text-content-muted">Every call is recorded in Activity.</p>
</div>
)
}
+12
View File
@@ -156,6 +156,9 @@ export interface AutomationToken {
grants: string[]
requireScope: boolean
hostAllow?: string[]
/** Lets tools return the values of Authorization, Cookie and similar headers
* rather than masking them. Off by default. */
allowCredentials: boolean
rateLimitPerMin: number
maxConcurrent: number
maxOutputBytes: number
@@ -180,6 +183,7 @@ export interface AutomationTokenInput {
grants?: string[]
requireScope?: boolean
hostAllow?: string[]
allowCredentials?: boolean
rateLimitPerMin?: number
maxConcurrent?: number
maxOutputBytes?: number
@@ -198,6 +202,9 @@ export interface Capability {
* authorization control is scope must not edit scope; one the operator has
* exempted from scope already reaches every host, so editing it grants nothing. */
unrestrictedOnly: boolean
/** Execution or C2. Registered only under --automation-privileged, and never
* bundled into a profile — the operator selects each by hand. */
privileged: boolean
inputSchema: unknown
maxOutputBytes: number
/** The MCP tool name: the capability ID with dots replaced by underscores. */
@@ -216,6 +223,7 @@ export interface AutomationProfile {
* unrestrictedOnly capability leaves this false or those grants are always denied. */
requireScope: boolean
allowsSends: boolean
allowsCredentials: boolean
rateLimitPerMin: number
maxConcurrent: number
}
@@ -241,6 +249,10 @@ export interface AuditEntry {
targetMethod?: string
targetPath?: string
requireScope: boolean
/** This call could return unmasked Authorization and Cookie values. */
credentials?: boolean
/** An execution or C2 invocation. */
privileged?: boolean
/** A digest, not the arguments: arguments to a send carry credentials and
* payloads, and retaining them would make this a secondary secret store. */
argsDigest?: string