mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Added additional mcp capabilities for automation support
This commit is contained in:
1 parent
fa7df9244a
commit
04833cad99
39 files changed
+2254
-244
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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, ".", "_"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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, "|")
|
||||
}
|
||||
@@ -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 + `,
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 "+
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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, " "))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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, ", "))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user