mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Add SJ and Chain tabs for offensive testing of API specification files and business logic flaw/workflow sequence testing
This commit is contained in:
1 parent
043262089c
commit
c3f97d331f
128 files changed
+18870
-219
No files matched your search
@@ -14,6 +14,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,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})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"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/proxy"
|
||||
@@ -206,6 +207,16 @@ 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"`
|
||||
}
|
||||
|
||||
// encodePluginStates base64-encodes each blob for transport inside a JSON
|
||||
@@ -337,10 +348,11 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
dEnabled, dCfg, dDisabled, dOverrides, dRules, dFindings := s.detectStateForProject()
|
||||
|
||||
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,
|
||||
// v8 added Chains; v7 added AutomationStates. Neither needs a
|
||||
// normalizeProjectConfig gate: both decode to nil when absent, which is the
|
||||
// correct default — a backfill exists only where the zero value would be
|
||||
// wrong, as it was for autoSave and detectEnabled.
|
||||
Version: 8,
|
||||
AutoSave: autoSave,
|
||||
SaveHistory: saveHistory,
|
||||
ListenerURL: listenerURL,
|
||||
@@ -371,6 +383,8 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,10 +561,18 @@ 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()
|
||||
}
|
||||
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())) +
|
||||
len(s.customData.Items()), len(s.noise.Patterns()), chainCount, chainRev) +
|
||||
s.detectSignature() + s.automationSignature()
|
||||
}
|
||||
|
||||
@@ -623,6 +645,26 @@ 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()
|
||||
}
|
||||
|
||||
// An automation's key/value state describes the engagement it was gathered in.
|
||||
if s.automationStorage != nil {
|
||||
s.automationStorage.Reset()
|
||||
@@ -703,6 +745,27 @@ 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.chainStore != nil {
|
||||
s.chainStore.ReplaceAll(cfg.Chains)
|
||||
}
|
||||
|
||||
// Apply team server settings.
|
||||
s.mu.Lock()
|
||||
nickname := cfg.TeamNickname
|
||||
|
||||
@@ -98,7 +98,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,
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 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()
|
||||
// The default rather than an operator value: a load carries no profile and
|
||||
// no tab exists yet to have set one.
|
||||
raw := "GET " + target + " HTTP/1.1\r\nHost: " + u.Host + "\r\n" +
|
||||
"User-Agent: " + apispec.DefaultUserAgent + "\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.
|
||||
//
|
||||
// The most common case by far is a Swagger UI page: Joro's own detect rule
|
||||
// flags those under the same finding as a real document, because its pattern
|
||||
// matches the string "swagger-ui". Answering "that is the UI, and here is where
|
||||
// it usually loads its document from" turns the most common failure into the
|
||||
// next action rather than a dead end.
|
||||
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
|
||||
}
|
||||
|
||||
candidates := apispec.ExtractSpecRefs(body, sourceURL)
|
||||
if len(candidates) == 0 {
|
||||
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
|
||||
candidates = append(candidates, p)
|
||||
if len(candidates) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"error": "that URL served an HTML page, not an API description document",
|
||||
"kind": "html",
|
||||
"contentType": contentType,
|
||||
"sourceUrl": sourceURL,
|
||||
"candidates": candidates,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -109,6 +109,64 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("DELETE /api/v1/fuzzer/campaigns/{id}", s.handleFuzzerDeleteCampaign)
|
||||
mux.HandleFunc("POST /api/v1/fuzzer/wordlist", s.handleFuzzerUploadWordlist)
|
||||
|
||||
// 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)
|
||||
|
||||
// Passive detection. Unlike the other rule collections below, detect rules
|
||||
// have a PUT (a rule's ID is referenced by every finding it produced) and a
|
||||
// test endpoint.
|
||||
|
||||
@@ -15,10 +15,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@@ -99,6 +102,22 @@ 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
|
||||
|
||||
// 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.
|
||||
@@ -246,6 +265,10 @@ func New(
|
||||
sliverClient: sc,
|
||||
mythicClient: mc,
|
||||
fuzzerStore: fuzzer.NewStore(),
|
||||
specStore: apiscan.NewSpecStore(),
|
||||
specRuns: apiscan.NewStore(),
|
||||
chainStore: chain.NewStore(),
|
||||
chainRuns: chainrun.NewStore(),
|
||||
pluginManager: pluginManager,
|
||||
|
||||
detectEngine: detectEngine,
|
||||
|
||||
@@ -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,312 @@
|
||||
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")
|
||||
}
|
||||
|
||||
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,705 @@
|
||||
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 sent as-is. Empty means DefaultUserAgent, and a User-Agent
|
||||
// the operator named through an auth profile or an "in: header" parameter
|
||||
// outranks both — the request carries exactly one either way.
|
||||
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.
|
||||
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 ua != "" && validHeaderValue(ua) {
|
||||
return ua
|
||||
}
|
||||
return DefaultUserAgent
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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:
|
||||
headers = append(headers, Header{Name: prm.Name, Value: mark(key, raw)})
|
||||
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)
|
||||
|
||||
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)
|
||||
if !hasUserAgent(headers) {
|
||||
writeHeader(&buf, "User-Agent", UserAgentOr(opts.UserAgent))
|
||||
}
|
||||
accept := opts.Accept
|
||||
if accept == "" {
|
||||
accept = defaultAccept
|
||||
}
|
||||
writeHeader(&buf, "Accept", accept)
|
||||
|
||||
for _, h := range headers {
|
||||
if reservedHeader(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
|
||||
}
|
||||
|
||||
// hasUserAgent reports whether the operator already named one, through an auth
|
||||
// profile's extra headers or a document's own "in: header" parameter.
|
||||
//
|
||||
// Both land in the same slice, which applyAuth appends to without deduping and
|
||||
// writeHeader emits without case-folding — so this check is what makes a named
|
||||
// User-Agent an override rather than a second header line. It is deliberately
|
||||
// case- and space-insensitive: a document chooses that spelling, not Joro.
|
||||
func hasUserAgent(headers []Header) bool {
|
||||
for _, h := range headers {
|
||||
if strings.EqualFold(strings.TrimSpace(h.Name), "User-Agent") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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, "; ")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func Batch(ctx context.Context, d ResendDeps, args BatchArgs) (string, error) {
|
||||
// One claim set per batch, so two workers finishing at once cannot correlate
|
||||
// their sends to the same history row.
|
||||
sendDeps := d.Send
|
||||
sendDeps.Claims = newClaimSet()
|
||||
sendDeps.Claims = NewClaimSet()
|
||||
|
||||
// Results land in a pre-sized slice indexed by variant, not appended. Order
|
||||
// matters more here than in the fuzzer, because the client correlates rows
|
||||
@@ -173,7 +173,7 @@ func Batch(ctx context.Context, d ResendDeps, args BatchArgs) (string, error) {
|
||||
continue
|
||||
}
|
||||
rows[idx].seq = res.Seq
|
||||
rows[idx].fp = fingerprintResponse(res.Seq, res.RespRaw, res.Duration.Milliseconds(), false)
|
||||
rows[idx].fp = FingerprintResponse(res.Seq, res.RespRaw, res.Duration.Milliseconds(), false)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -136,8 +136,14 @@ var (
|
||||
spaceRunRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// fingerprintResponse computes a Fingerprint from raw response bytes.
|
||||
func fingerprintResponse(seq int, raw []byte, durationMs int64, wantFull bool) Fingerprint {
|
||||
// FingerprintResponse computes a Fingerprint from raw response bytes.
|
||||
//
|
||||
// Exported for callers outside this package that compare responses to each
|
||||
// other rather than reading them. StructHash is the reason: comparing status
|
||||
// and length misses "same page, different nonce" in both directions — two
|
||||
// renderings of one page differ in length, and two different pages can share
|
||||
// one. Any such caller wants this, not its own comparison.
|
||||
func FingerprintResponse(seq int, raw []byte, durationMs int64, wantFull bool) Fingerprint {
|
||||
m := parseMessage(raw, true)
|
||||
|
||||
bodySum := sha256.Sum256(m.Body)
|
||||
|
||||
@@ -310,7 +310,7 @@ func FingerprintSeqs(store *proxy.Store, args FingerprintArgs) (string, error) {
|
||||
fps = append(fps, Fingerprint{Seq: seq, Err: "no response was captured"})
|
||||
continue
|
||||
}
|
||||
fps = append(fps, fingerprintResponse(seq, item.RespRaw, item.Duration.Milliseconds(), wantFull))
|
||||
fps = append(fps, FingerprintResponse(seq, item.RespRaw, item.Duration.Milliseconds(), wantFull))
|
||||
}
|
||||
return renderFingerprints(fps, wantFull), nil
|
||||
}
|
||||
@@ -61,6 +61,46 @@ func parseMessage(raw []byte, decode bool) *message {
|
||||
return m
|
||||
}
|
||||
|
||||
// UpdateContentLength re-frames a raw request after its body has been rewritten.
|
||||
//
|
||||
// A pass-through to proxy.UpdateContentLength, exported here because this is the
|
||||
// package that owns raw-request editing and every ApplyEdits caller has to follow
|
||||
// with it — a body op that does not re-frame produces a request the origin reads
|
||||
// as truncated, which looks exactly like the server rejecting it. Having it
|
||||
// beside ApplyEdits is also what lets internal/chain render a step without
|
||||
// importing internal/proxy, and so without acquiring a send path.
|
||||
func UpdateContentLength(raw []byte) []byte { return proxy.UpdateContentLength(raw) }
|
||||
|
||||
// Response is a parsed, decoded response.
|
||||
type Response struct {
|
||||
Status int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
|
||||
// Decoded names the content encoding that was unwrapped, or "" if none. It
|
||||
// carries the same "(not decoded)" suffix parseMessage uses for br and zstd.
|
||||
Decoded string
|
||||
}
|
||||
|
||||
// ReadResponse parses and decompresses raw response bytes.
|
||||
//
|
||||
// Exported for the same reason FingerprintResponse is: a caller outside this
|
||||
// package that needs to read a value out of a response would otherwise write a
|
||||
// third copy of split-headers-then-maybe-gunzip, and the decode half is not
|
||||
// obvious — TransportConfig sets DisableCompression and the proxy leaves
|
||||
// Content-Encoding in place, so a captured body is whatever the origin sent.
|
||||
// internal/chain uses this to resolve a chain's data dependencies, which is also
|
||||
// how it stays free of any import that could open a socket.
|
||||
func ReadResponse(raw []byte) Response {
|
||||
m := parseMessage(raw, true)
|
||||
return Response{
|
||||
Status: m.Status,
|
||||
Header: m.Header,
|
||||
Body: m.Body,
|
||||
Decoded: m.Decoded,
|
||||
}
|
||||
}
|
||||
|
||||
// contentType returns the message's Content-Type keyword.
|
||||
func (m *message) contentType() string {
|
||||
if m == nil || m.Header == nil {
|
||||
|
||||
@@ -21,10 +21,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// maxProxyRespBody bounds what a single automation send will read. The fuzzer
|
||||
// uses 10 MB; we need only enough to fingerprint, and a fifty-item batch at
|
||||
// 10 MB each would be half a gigabyte of transient allocation.
|
||||
maxProxyRespBody = 2 << 20
|
||||
// MaxProxyRespBody bounds what a single send will read. The fuzzer uses
|
||||
// 10 MB; we need only enough to fingerprint, and a fifty-item batch at 10 MB
|
||||
// each would be half a gigabyte of transient allocation.
|
||||
//
|
||||
// Exported because a caller that reads the body for its own purposes, rather
|
||||
// than fingerprinting it, has to be able to tell a truncated response from a
|
||||
// complete one. A spec fetch that silently loses its tail fails later with a
|
||||
// parse error about the document, which sends the operator looking in
|
||||
// entirely the wrong place.
|
||||
MaxProxyRespBody = 2 << 20
|
||||
|
||||
// correlateWindow is how long to wait for the capture to appear in the store
|
||||
// after the response arrives. Capture happens on the proxy's goroutine, so it
|
||||
@@ -37,7 +43,10 @@ const (
|
||||
type ProxySendResult struct {
|
||||
// Seq is the history sequence number of the resulting capture, or 0 when the
|
||||
// request could not be correlated — see SendViaProxy for when that happens.
|
||||
// RequestID addresses the same capture for GET /api/v1/requests/{id}, which
|
||||
// is the only route that resolves one; it is empty exactly when Seq is 0.
|
||||
Seq int
|
||||
RequestID string
|
||||
SeqNote string
|
||||
RespRaw []byte
|
||||
StatusCode int
|
||||
@@ -57,7 +66,17 @@ type SendDeps struct {
|
||||
// Claims is optional and only needed where sends run concurrently: it stops
|
||||
// two batch workers correlating to the same history row. A single send passes
|
||||
// nil, for which claim is a no-op.
|
||||
Claims *claimSet
|
||||
Claims *ClaimSet
|
||||
|
||||
// MaxRespBytes overrides MaxProxyRespBody for this send. Zero keeps the
|
||||
// default.
|
||||
//
|
||||
// The default is sized against fifty-item batches that only need enough body
|
||||
// to fingerprint. A caller that reads the body for its own purposes — fetching
|
||||
// an API description document, which is routinely megabytes — needs a larger
|
||||
// one, and raising the shared constant would multiply every batch's transient
|
||||
// allocation to buy it.
|
||||
MaxRespBytes int
|
||||
}
|
||||
|
||||
// SendViaProxy writes raw request bytes through Joro's own proxy listener.
|
||||
@@ -112,7 +131,11 @@ func SendViaProxy(ctx context.Context, raw []byte, scheme, host string, d SendDe
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("dumping response: %w", err)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxProxyRespBody))
|
||||
limit := d.MaxRespBytes
|
||||
if limit <= 0 {
|
||||
limit = MaxProxyRespBody
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, int64(limit)))
|
||||
resp.Body.Close()
|
||||
respRaw = append(respRaw, body...)
|
||||
|
||||
@@ -124,7 +147,7 @@ func SendViaProxy(ctx context.Context, raw []byte, scheme, host string, d SendDe
|
||||
Method: method,
|
||||
URL: scheme + "://" + urlHost(hostPort, scheme) + target,
|
||||
}
|
||||
res.Seq, res.SeqNote = correlate(d.Store, lo, res, body, d.Claims)
|
||||
res.Seq, res.RequestID, res.SeqNote = correlate(d.Store, lo, res, body, d.Claims)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -267,17 +290,27 @@ func toAbsoluteForm(raw []byte, hostPort string) []byte {
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// claimSet tracks capture sequence numbers already handed to a send, so two
|
||||
// concurrent batch workers cannot correlate to the same history row.
|
||||
type claimSet struct {
|
||||
// ClaimSet tracks capture sequence numbers already handed to a send, so two
|
||||
// concurrent workers cannot correlate to the same history row.
|
||||
//
|
||||
// Any caller running SendViaProxy concurrently needs one, including callers in
|
||||
// other packages — hence the export. Leaving SendDeps.Claims nil is correct only
|
||||
// for a single send: with concurrency, two responses that happen to be
|
||||
// byte-identical fall through correlate's disambiguation to claim order, and
|
||||
// without a claim set both take the same row. Sends that vary only an
|
||||
// Authorization header make that the common case, not the rare one.
|
||||
type ClaimSet struct {
|
||||
mu sync.Mutex
|
||||
m map[int]struct{}
|
||||
}
|
||||
|
||||
func newClaimSet() *claimSet { return &claimSet{m: map[int]struct{}{}} }
|
||||
// NewClaimSet returns a claim set for one batch of concurrent sends. Scope it to
|
||||
// the batch: sharing one across unrelated runs would let an earlier run's claim
|
||||
// block a later one from correlating at all.
|
||||
func NewClaimSet() *ClaimSet { return &ClaimSet{m: map[int]struct{}{}} }
|
||||
|
||||
// claim takes seq if it is unclaimed, reporting whether it succeeded.
|
||||
func (c *claimSet) claim(seq int) bool {
|
||||
func (c *ClaimSet) claim(seq int) bool {
|
||||
if c == nil {
|
||||
return true
|
||||
}
|
||||
@@ -315,9 +348,13 @@ func (c *claimSet) claim(seq int) bool {
|
||||
// tunneled with no capture anywhere, and a host outside the proxy's own capture
|
||||
// scope is forwarded without one. The note says so rather than leaving a client to
|
||||
// guess why a seq it was given does not resolve.
|
||||
func correlate(store *proxy.Store, lo int, res *ProxySendResult, body []byte, claims *claimSet) (int, string) {
|
||||
// The capture's ID is returned alongside its Seq because they address different
|
||||
// things: Seq is the watermark this function matches on, but GET
|
||||
// /api/v1/requests/{id} is the only route that resolves a capture, so a client
|
||||
// given only a seq cannot open the row it names.
|
||||
func correlate(store *proxy.Store, lo int, res *ProxySendResult, body []byte, claims *ClaimSet) (seq int, id, note string) {
|
||||
if store == nil {
|
||||
return 0, "no capture store"
|
||||
return 0, "", "no capture store"
|
||||
}
|
||||
deadline := time.Now().Add(correlateWindow)
|
||||
for {
|
||||
@@ -328,7 +365,7 @@ func correlate(store *proxy.Store, lo int, res *ProxySendResult, body []byte, cl
|
||||
}
|
||||
if len(body) > 0 && bytes.HasSuffix(c.RespRaw, body) {
|
||||
if claims.claim(c.Seq) {
|
||||
return c.Seq, ""
|
||||
return c.Seq, c.ID, ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -337,10 +374,10 @@ func correlate(store *proxy.Store, lo int, res *ProxySendResult, body []byte, cl
|
||||
}
|
||||
}
|
||||
if fallback != nil && claims.claim(fallback.Seq) {
|
||||
return fallback.Seq, ""
|
||||
return fallback.Seq, fallback.ID, ""
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return 0, "not captured (host may be noise-filtered or outside the proxy's capture scope)"
|
||||
return 0, "", "not captured (host may be noise-filtered or outside the proxy's capture scope)"
|
||||
}
|
||||
time.Sleep(correlatePoll)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func Resend(ctx context.Context, d ResendDeps, args ResendArgs) (string, error)
|
||||
d.Contexts.Capture(d.TokenID, u, res.RespRaw)
|
||||
}
|
||||
|
||||
fp := fingerprintResponse(res.Seq, res.RespRaw, res.Duration.Milliseconds(), false)
|
||||
fp := FingerprintResponse(res.Seq, res.RespRaw, res.Duration.Milliseconds(), false)
|
||||
return renderResend(args, res, fp, supplied), nil
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -22,6 +22,8 @@ import Detect from './pages/Detect'
|
||||
import Intercept from './pages/Intercept'
|
||||
import Manipulate from './pages/Manipulate'
|
||||
import Fuzz from './pages/Fuzz'
|
||||
import SJ from './pages/SJ'
|
||||
import Chain from './pages/Chain'
|
||||
import DeadDrop from './pages/DeadDrop'
|
||||
import Generator from './pages/Generator'
|
||||
import Executor from './pages/Executor'
|
||||
@@ -295,7 +297,7 @@ export default function App() {
|
||||
onClick={() => useStreamerStore.getState().toggle()}
|
||||
title={
|
||||
streamerOn
|
||||
? 'Streamer mode on — infrastructure and chrome are hidden. Captured traffic is not. (Ctrl/Cmd+Shift+.)'
|
||||
? 'Streamer mode on - infrastructure and chrome are hidden. Captured traffic is not. (Ctrl/Cmd+Shift+.)'
|
||||
: 'Streamer mode off (Ctrl/Cmd+Shift+.)'
|
||||
}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded-sm hover:bg-surface-hover ${
|
||||
@@ -323,6 +325,8 @@ export default function App() {
|
||||
<Route path="/intercept" element={<Intercept />} />
|
||||
<Route path="/manipulate" element={<Manipulate />} />
|
||||
<Route path="/fuzz" element={<Fuzz />} />
|
||||
<Route path="/sj" element={<SJ />} />
|
||||
<Route path="/chain" element={<Chain />} />
|
||||
<Route path="/deaddrop" element={<DeadDrop />} />
|
||||
<Route path="/generator" element={<Generator />} />
|
||||
<Route path="/executor" element={<Executor />} />
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function AutomationActivity() {
|
||||
)}
|
||||
{e.outputBytes > 0 ? `${e.outputBytes}B ` : ''}
|
||||
{e.durationMs}ms
|
||||
{e.errMsg ? <> — <Redacted value={e.errMsg} kind="text" /></> : ''}
|
||||
{e.errMsg ? <> - <Redacted value={e.errMsg} kind="text" /></> : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -50,12 +50,12 @@ export default function AutomationSecretModal({ secret, tokenName, endpoint, onC
|
||||
Token for <Redacted value={tokenName} kind="identity" />
|
||||
</h3>
|
||||
<p className="text-[11px] text-content-muted">
|
||||
This is the only time this secret is shown. Joro stores a hash of it, not the value — if you lose it,
|
||||
This is the only time this secret is shown. Joro stores a hash of it, not the value - if you lose it,
|
||||
rotate the token to issue a new one.
|
||||
</p>
|
||||
{streamerOn && (
|
||||
<p className="text-[11px] text-semantic-warning">
|
||||
Streamer mode is hiding the value. Copy still yields the real secret — copy it before closing, or
|
||||
Streamer mode is hiding the value. Copy still yields the real secret - copy it before closing, or
|
||||
turn streamer mode off to read it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function AutomationTokenModal({
|
||||
<Unlock size={12} strokeWidth={2} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
This token can add scope rules. Scope is what decides which hosts Joro intercepts, so it can make Joro
|
||||
terminate TLS for and record hosts you have not scoped — and it can already read all captured traffic.
|
||||
terminate TLS for and record hosts you have not scoped - and it can already read all captured traffic.
|
||||
It can only add include rules and enable scope; it cannot exclude, remove, or disable.
|
||||
</span>
|
||||
</p>
|
||||
@@ -195,7 +195,7 @@ export default function AutomationTokenModal({
|
||||
<KeyRound size={12} strokeWidth={2} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
This token can run commands: {privileged.map((c) => c.toolName).join(', ')}. Scope and the host whitelist
|
||||
do not bound the C2 capabilities — they describe web targets, not a team server — so this grant is the
|
||||
do not bound the C2 capabilities - they describe web targets, not a team server - so this grant is the
|
||||
only limit on what they reach.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function CollabSwapModal({ collabId, onClose, onApplied }: Props)
|
||||
<p className="text-xs text-content-secondary mt-1">
|
||||
<span className="text-accent-secondary font-medium">{req.requestor}</span>
|
||||
{req.project && <> on <span className="text-content-primary">{req.project}</span></>}
|
||||
{req.note && <> — “{req.note}”</>}
|
||||
{req.note && <> - “{req.note}”</>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function DashboardLayoutSettings({
|
||||
{hiddenFromTeam && (
|
||||
<div className="border-l-2 border-semantic-warning pl-2.5 py-1 text-[10px] text-content-secondary leading-relaxed">
|
||||
Without the <span className="text-semantic-warning font-semibold">Team Chat</span> widget
|
||||
you are hidden from the team roster — presence is only announced while chat is on your
|
||||
you are hidden from the team roster - presence is only announced while chat is on your
|
||||
dashboard.
|
||||
</div>
|
||||
)}
|
||||
@@ -68,7 +68,7 @@ export default function DashboardLayoutSettings({
|
||||
{dashboardPlugin && (
|
||||
<div className="border-l-2 border-semantic-warning pl-2.5 py-1 text-[10px] text-content-secondary leading-relaxed">
|
||||
The <span className="text-semantic-warning font-semibold">{dashboardPlugin}</span> plugin
|
||||
replaces the built-in dashboard — these settings won't be visible until it's removed.
|
||||
replaces the built-in dashboard - these settings won't be visible until it's removed.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function DashboardLayoutSettings({
|
||||
const reason = isAvailable(w, ctx) ? '' : unavailableReason(w, ctx)
|
||||
return (
|
||||
<option key={w.id} value={w.id} title={w.description}>
|
||||
{w.label}{reason ? ` — ${reason}` : ''}
|
||||
{w.label}{reason ? ` - ${reason}` : ''}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function DetectRuleModal({
|
||||
// Cancel.
|
||||
const guardedClose = useCallback(() => {
|
||||
if (draft && dirty) {
|
||||
addToast('Unsaved rule changes — use Cancel to discard', 'info')
|
||||
addToast('Unsaved rule changes - use Cancel to discard', 'info')
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
@@ -542,7 +542,7 @@ function RuleForm({
|
||||
<div className="text-semantic-error text-[10px]">{localRegexError}</div>
|
||||
)}
|
||||
<div className="text-[10px] text-content-muted mt-0.5">
|
||||
Client-side preview only — the backend uses Go RE2, which rejects lookahead,
|
||||
Client-side preview only - the backend uses Go RE2, which rejects lookahead,
|
||||
lookbehind, and backreferences. Save or Test to validate for real.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +333,7 @@ function PrivilegedWarning({ caps }: { caps: Capability[] }) {
|
||||
|
||||
<p>
|
||||
The token’s secret is a bearer credential: anything holding it can invoke this. Joro’s tokens scope and
|
||||
record what an agent does — they are not a sandbox on the machine.
|
||||
record what an agent does - they are not a sandbox on the machine.
|
||||
</p>
|
||||
|
||||
{hasExec && (
|
||||
@@ -344,17 +344,17 @@ function PrivilegedWarning({ caps }: { caps: Capability[] }) {
|
||||
)}
|
||||
{hasC2 && (
|
||||
<p className="text-semantic-warning">
|
||||
Scope and the host whitelist do <strong>not</strong> bound the C2 capabilities — they describe web
|
||||
Scope and the host whitelist do <strong>not</strong> bound the C2 capabilities - they describe web
|
||||
targets, not a C2 server. This grant is the only limit on what they reach.
|
||||
</p>
|
||||
)}
|
||||
{hasScript && (
|
||||
<p className="text-semantic-warning">
|
||||
<code className="font-mono">script_run</code> authorizes the <strong>whole standard automation SDK</strong>{' '}
|
||||
for the code it runs, not just the capabilities ticked here — reading captured traffic, resending and
|
||||
for the code it runs, not just the capabilities ticked here - reading captured traffic, resending and
|
||||
fuzzing, and writing findings and notes. That is what the grant means, not a loophole in it. This token’s
|
||||
own scope requirement, host whitelist and credential setting still apply to every request the code makes
|
||||
— a script inherits this token’s reach, it does not widen it.
|
||||
- a script inherits this token’s reach, it does not widen it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function HealthCheck({ onFinish }: Props) {
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold text-content-primary">Open the testing browser</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Opens a browser routed through the proxy with the CA trusted for this project — no cert install needed.
|
||||
Opens a browser routed through the proxy with the CA trusted for this project - no cert install needed.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<button
|
||||
@@ -125,7 +125,7 @@ export default function HealthCheck({ onFinish }: Props) {
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold text-content-primary">Verify capture</p>
|
||||
{captured ? (
|
||||
<p className="text-xs text-semantic-success mt-0.5">Traffic captured — you're all set.</p>
|
||||
<p className="text-xs text-semantic-success mt-0.5">Traffic captured - you're all set.</p>
|
||||
) : (
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Browse to any HTTPS site in the testing browser; the first captured request appears here.
|
||||
|
||||
@@ -201,7 +201,7 @@ function ManagePanel({ plugins, onRefresh }: { plugins: PluginInfo[]; onRefresh:
|
||||
<tbody>
|
||||
{plugins.map((p) => (
|
||||
<tr key={p.filename} className="border-t border-border-subtle hover:bg-surface-hover">
|
||||
<td className="px-3 py-2 text-content-primary font-medium">{p.name || '—'}</td>
|
||||
<td className="px-3 py-2 text-content-primary font-medium">{p.name || '-'}</td>
|
||||
<td className="px-3 py-2 text-content-secondary">{p.version}</td>
|
||||
<td className="px-3 py-2">
|
||||
{/* No `/20` opacity here: Tailwind can't apply an opacity
|
||||
@@ -270,7 +270,7 @@ function ManagePanel({ plugins, onRefresh }: { plugins: PluginInfo[]; onRefresh:
|
||||
message={
|
||||
confirmDelete.status === 'error'
|
||||
? `Delete ${confirmDelete.filename}? This file never loaded, so nothing is running and no restart is needed. This cannot be undone.`
|
||||
: `Delete ${confirmDelete.filename}? ${confirmDelete.name} keeps running until you restart Joro — its code, routes and hooks stay live. This cannot be undone.`
|
||||
: `Delete ${confirmDelete.filename}? ${confirmDelete.name} keeps running until you restart Joro - its code, routes and hooks stay live. This cannot be undone.`
|
||||
}
|
||||
body={
|
||||
confirmDelete.name ? (
|
||||
|
||||
@@ -20,9 +20,9 @@ function plural(n: number, noun: string): string {
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return '—'
|
||||
if (isNaN(d.getTime())) return '-'
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ export default function ProjectBrowser() {
|
||||
title="Delete project"
|
||||
message={
|
||||
confirmDelete.active
|
||||
? `Delete ${redactNow(confirmDelete.name, 'identity')}? Its project file and testing-browser profile are removed. Your session stays loaded as an unnamed Scratch session — save it under a new name to keep it. This cannot be undone.`
|
||||
? `Delete ${redactNow(confirmDelete.name, 'identity')}? Its project file and testing-browser profile are removed. Your session stays loaded as an unnamed Scratch session - save it under a new name to keep it. This cannot be undone.`
|
||||
: `Delete ${redactNow(confirmDelete.name, 'identity')}? Its project file and testing-browser profile are removed. This cannot be undone.`
|
||||
}
|
||||
body={
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function ProjectSettings() {
|
||||
{unknownPluginStatesNotice.length > 0 && (
|
||||
<div className="bg-surface-card rounded border border-border p-3 text-xs text-content-secondary">
|
||||
State preserved for: <span className="text-semantic-warning font-semibold">{unknownPluginStatesNotice.join(', ')}</span>
|
||||
{' '}— these plugins aren't installed on this system. The blobs round-trip on re-save.
|
||||
{' '}- these plugins aren't installed on this system. The blobs round-trip on re-save.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function ProjectSettings() {
|
||||
<span className="text-content-secondary">{teamStatus(teamConn).label}</span>
|
||||
{teamConn === 'disconnected' && teamConnError && (
|
||||
<span className="text-content-muted truncate">
|
||||
— <Redacted value={teamConnError} kind="url" />
|
||||
- <Redacted value={teamConnError} kind="url" />
|
||||
{teamConnHTTP ? ` (HTTP ${teamConnHTTP})` : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,12 @@ export function usePrettyJson(): [boolean, (next: boolean) => void] {
|
||||
|
||||
type Props = { raw: string; prettyJson: boolean }
|
||||
|
||||
// The iframe is absolutely positioned so it fills its pane without a height
|
||||
// measurement, which means the caller MUST give it a positioned ancestor —
|
||||
// `relative` on the containing pane. `overflow-auto` does not count: overflow
|
||||
// establishes no containing block. Without one the iframe resolves against the
|
||||
// initial containing block, covers the whole viewport on top of the header and
|
||||
// banners, and swallows every pointer event in the window until a reload.
|
||||
export function ResponseRender({ raw, prettyJson }: Props) {
|
||||
const url = useMemo(() => createResponseBlobUrl(raw, { prettyJson }), [raw, prettyJson])
|
||||
useEffect(() => () => URL.revokeObjectURL(url), [url])
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function SitemapFilterModal({ filter, onChange, onClose, onClear
|
||||
options={HTTP_METHOD_OPTIONS}
|
||||
selected={filter.methods}
|
||||
onChange={(methods) => onChange({ methods })}
|
||||
tooltip="Filter by HTTP method — select any number"
|
||||
tooltip="Filter by HTTP method - select any number"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function StatusFilter({ classes, codes, onChange, label = 'Status
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip content="Exact codes and inclusive ranges, comma-separated — OR'd with the class chips">
|
||||
<Tooltip content="Exact codes and inclusive ranges, comma-separated - OR'd with the class chips">
|
||||
<input
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border w-28"
|
||||
placeholder="403,500-599"
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function AutomationConfigPanel({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-content-muted">
|
||||
A grant change does not push to a connected client — this transport initiates no messages. The
|
||||
A grant change does not push to a connected client - this transport initiates no messages. The
|
||||
client picks it up on its next tool listing, or on reconnect.
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@ export default function AutomationConfigPanel({
|
||||
<ShieldAlert size={15} strokeWidth={2} className="text-semantic-warning shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<p className="text-[11px] leading-snug">
|
||||
A token that can send traffic is active, but scope is off or has no rules. Those sends are being{' '}
|
||||
<strong>refused</strong>, by design — set an include rule in Settings → Project → Filtering, or
|
||||
<strong>refused</strong>, by design - set an include rule in Settings → Project → Filtering, or
|
||||
reissue the token with the scope requirement turned off.
|
||||
</p>
|
||||
</div>
|
||||
@@ -306,7 +306,7 @@ export default function AutomationConfigPanel({
|
||||
|
||||
<p className="text-[10px] text-content-muted leading-snug">
|
||||
Tokens limit what an automation client can do. They do not restrict other programs on this machine
|
||||
— Joro’s local API is open to local processes by design. Requests sent by automation go
|
||||
- Joro’s local API is open to local processes by design. Requests sent by automation go
|
||||
through Joro’s proxy and appear in History, where Match & Replace and Custom Data rules
|
||||
apply to them like any other request.
|
||||
</p>
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function AutomationHeader({
|
||||
? 'Disable'
|
||||
: paused
|
||||
? 'Enable (clears the pause)'
|
||||
: 'Enable — arms every trigger below'
|
||||
: 'Enable - arms every trigger below'
|
||||
}
|
||||
>
|
||||
{enabled ? <Power size={11} strokeWidth={2.2} /> : <PowerOff size={11} strokeWidth={2.2} />}
|
||||
@@ -443,13 +443,13 @@ export function AutomationOptions({
|
||||
be examined would otherwise read an empty result as nothing having been found. */
|
||||
<p className="text-[10px] text-semantic-warning leading-snug">
|
||||
A command sees the most recent request, at most once per interval, and skips whatever
|
||||
arrived in between — it samples traffic rather than examining all of it. Joro cannot tell
|
||||
arrived in between - it samples traffic rather than examining all of it. Joro cannot tell
|
||||
whether a program sent anything, so it always assumes it did and moves past its own window.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-semantic-warning leading-snug">
|
||||
A traffic-triggered automation that sends requests skips the traffic its own run produced,
|
||||
so it cannot trigger itself — but it will also miss whatever else was captured during that
|
||||
so it cannot trigger itself - but it will also miss whatever else was captured during that
|
||||
run.
|
||||
</p>
|
||||
))}
|
||||
@@ -501,7 +501,7 @@ export function AutomationOptions({
|
||||
{id && (
|
||||
<label className="block">
|
||||
<span className="block text-[10px] text-content-muted">
|
||||
Order in the strip — lower sits left
|
||||
Order in the strip - lower sits left
|
||||
</span>
|
||||
{/* An operator preference, not the author's: written to the sidecar through
|
||||
the prefs endpoint, so updating the code cannot revert it. Hence the
|
||||
@@ -521,8 +521,8 @@ export function AutomationOptions({
|
||||
does not exist here. */
|
||||
<p className="text-[10px] text-semantic-warning leading-snug">
|
||||
Receives the bytes on screen on its standard input and its output becomes the tab. Unlike
|
||||
a script lens, this is not prevented from reaching the network — there are no
|
||||
capabilities to withhold from a program — so it runs every time you open a matching
|
||||
a script lens, this is not prevented from reaching the network - there are no
|
||||
capabilities to withhold from a program - so it runs every time you open a matching
|
||||
request.
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -283,12 +283,12 @@ export default function BudgetPanel({
|
||||
knowable: a script that loops forever, allocates without end, or calls the SDK ten
|
||||
thousand times stops at a number you chose, and a runaway costs a worker process
|
||||
rather than the proxy holding your captured traffic. It is also the leash on an agent
|
||||
— without it, one <code className="font-mono">script_run</code> call could turn into an
|
||||
- without it, one <code className="font-mono">script_run</code> call could turn into an
|
||||
open-ended sweep of a client’s systems.
|
||||
</p>
|
||||
<p className="text-[11px] text-content-muted leading-relaxed">
|
||||
Raise these when the defaults are too small for the work — a wider sweep, a longer
|
||||
comparison — and lower them when an agent should be kept on a shorter leash than you
|
||||
Raise these when the defaults are too small for the work - a wider sweep, a longer
|
||||
comparison - and lower them when an agent should be kept on a shorter leash than you
|
||||
are.
|
||||
</p>
|
||||
</div>
|
||||
@@ -323,7 +323,7 @@ export default function BudgetPanel({
|
||||
(sp) => maxPlaceholder(budget, sp),
|
||||
<>
|
||||
The <strong>default</strong> is what a run that asks for nothing gets. The{' '}
|
||||
<strong>max</strong> is the most a run may ask for — an agent calling{' '}
|
||||
<strong>max</strong> is the most a run may ask for - an agent calling{' '}
|
||||
<code className="font-mono">script_run</code> names its own figure, and anything above
|
||||
yours is trimmed to yours rather than refused. A blank box takes the greyed number
|
||||
beside it, which is Joro’s own; type over it and your number stands, however much
|
||||
@@ -344,7 +344,7 @@ export default function BudgetPanel({
|
||||
<>
|
||||
These belong to this Joro rather than to one run, so there is nothing for an automation
|
||||
or an agent to ask for: your number is the limit. The two agent figures are the
|
||||
exception — they share {Math.round(budget.agentOutputCap / 1024)} KB of tool result
|
||||
exception - they share {Math.round(budget.agentOutputCap / 1024)} KB of tool result
|
||||
between them, so their sum is checked as well as each one.
|
||||
</>
|
||||
)}
|
||||
@@ -361,15 +361,15 @@ export default function BudgetPanel({
|
||||
setCmdMaxima,
|
||||
(sp) => cmdMaxPlaceholder(budget, sp),
|
||||
<>
|
||||
What a local command automation is held to. A command is not sandboxed — it is a program
|
||||
on this machine with your filesystem and your network — so these are the bounds that do
|
||||
What a local command automation is held to. A command is not sandboxed - it is a program
|
||||
on this machine with your filesystem and your network - so these are the bounds that do
|
||||
apply: how long it may run, and how much of what it produced Joro keeps. There is no
|
||||
memory figure, and that is not an omission: a command is its own process, so an
|
||||
allocation without bound costs the command rather than Joro.
|
||||
</>,
|
||||
!budget.command.enabled ? (
|
||||
<span className="text-[10px] text-semantic-warning normal-case">
|
||||
not enabled — start Joro with{' '}
|
||||
not enabled - start Joro with{' '}
|
||||
<code className="font-mono">--automation-commands</code>
|
||||
</span>
|
||||
) : undefined
|
||||
|
||||
@@ -24,7 +24,7 @@ const SOURCE_LABELS: Record<string, { label: string; hint: string }> = {
|
||||
both: { label: 'Request + response', hint: 'Both, separated by a blank line.' },
|
||||
trigger: {
|
||||
label: 'Trigger event (JSON)',
|
||||
hint: 'The event payload. The only input a finding or a finished campaign has — neither carries a transaction.',
|
||||
hint: 'The event payload. The only input a finding or a finished campaign has - neither carries a transaction.',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ function CommandBox({
|
||||
<>
|
||||
<Section
|
||||
label="Command"
|
||||
hint="Split into a program and a list of arguments when you save. There is no shell — quotes group words, and every other character is literal."
|
||||
hint="Split into a program and a list of arguments when you save. There is no shell - quotes group words, and every other character is literal."
|
||||
action={
|
||||
<button
|
||||
onClick={onUseRows}
|
||||
@@ -357,8 +357,8 @@ function CommandBox({
|
||||
{usesInline && (
|
||||
<p className="text-[10px] text-semantic-warning leading-snug -mt-2">
|
||||
These bytes become part of the program's command line, which other processes on this
|
||||
machine can read. Piping them instead — <code className="font-mono">{INPUT_TOKEN} |</code>{' '}
|
||||
at the head — keeps them off it and has no size limit. Turn on credential masking under
|
||||
machine can read. Piping them instead - <code className="font-mono">{INPUT_TOKEN} |</code>{' '}
|
||||
at the head - keeps them off it and has no size limit. Turn on credential masking under
|
||||
Advanced for anything that sends them on.
|
||||
</p>
|
||||
)}
|
||||
@@ -562,7 +562,7 @@ function RowsEditor({
|
||||
<>
|
||||
<Section
|
||||
label="Program"
|
||||
hint="Resolved through PATH when you save, and stored as an absolute path — so the binary you review is the one that runs."
|
||||
hint="Resolved through PATH when you save, and stored as an absolute path - so the binary you review is the one that runs."
|
||||
action={
|
||||
mustUseRows ? undefined : (
|
||||
<button
|
||||
@@ -584,7 +584,7 @@ function RowsEditor({
|
||||
|
||||
<Section
|
||||
label="Arguments"
|
||||
hint="One per row, passed as a list. There is no shell, so nothing here is split, expanded or interpreted — an argument containing a space or a semicolon reaches the program as one argument."
|
||||
hint="One per row, passed as a list. There is no shell, so nothing here is split, expanded or interpreted - an argument containing a space or a semicolon reaches the program as one argument."
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{args.map((a, i) => (
|
||||
@@ -680,7 +680,7 @@ function Advanced({
|
||||
<div className="space-y-4 mt-3">
|
||||
<Section
|
||||
label="Input files"
|
||||
hint="Writes a part of the transaction into the working directory before the program starts. The key is the placeholder that resolves to its path — for a tool that wants a file rather than a pipe."
|
||||
hint="Writes a part of the transaction into the working directory before the program starts. The key is the placeholder that resolves to its path - for a tool that wants a file rather than a pipe."
|
||||
>
|
||||
<PairRows
|
||||
pairs={spec.files ?? {}}
|
||||
@@ -716,7 +716,7 @@ function Advanced({
|
||||
/>
|
||||
<label className="block mt-2">
|
||||
<span className="block text-[10px] text-content-muted mb-0.5">
|
||||
Inherit from Joro's environment — names, comma-separated
|
||||
Inherit from Joro's environment - names, comma-separated
|
||||
</span>
|
||||
<input
|
||||
className={`${inputCls} font-mono`}
|
||||
|
||||
@@ -542,7 +542,7 @@ export function FlowRail({
|
||||
{!active ? (
|
||||
<p className="text-[10px] text-content-muted leading-snug">
|
||||
Click a box to edit it. Drag from a port to wire two together, and drag a box to move it
|
||||
{derived ? '.' : ' — where you leave it is saved.'}
|
||||
{derived ? '.' : ' - where you leave it is saved.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -709,7 +709,7 @@ function portHelp(node: FlowNode, port: FlowPort, side: 'target' | 'source'): st
|
||||
if (side === 'source') {
|
||||
if (node.type === 'trigger') {
|
||||
return (
|
||||
'Carries ctx.trigger — why this run happened, including which trigger fired. ' +
|
||||
'Carries ctx.trigger - why this run happened, including which trigger fired. ' +
|
||||
'Drop it on Read field to pull one property out, or on any argument of an SDK call.'
|
||||
)
|
||||
}
|
||||
@@ -724,13 +724,13 @@ function portHelp(node: FlowNode, port: FlowPort, side: 'target' | 'source'): st
|
||||
}
|
||||
return port.kind === 'bool'
|
||||
? 'Carries a true or false. Only AND, OR, NOT, Choose and Stop unless accept one.'
|
||||
: 'Carries a value. Drop it on any input port — Read field, Text, Compare, or an argument of an SDK call.'
|
||||
: 'Carries a value. Drop it on any input port - Read field, Text, Compare, or an argument of an SDK call.'
|
||||
}
|
||||
|
||||
const many = port.many ? ' Takes more than one wire.' : ' Takes one wire.'
|
||||
const req = port.required ? ' Required.' : ''
|
||||
return port.kind === 'bool'
|
||||
? `Accepts a true or false — from Compare, AND, OR or NOT.${many}${req}`
|
||||
? `Accepts a true or false - from Compare, AND, OR or NOT.${many}${req}`
|
||||
: `Accepts any value.${many}${req}`
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function FlowNodeInspector({
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] text-content-muted leading-snug">
|
||||
Wired anywhere, this reads <code className="font-mono">ctx.trigger</code> — what woke the
|
||||
Wired anywhere, this reads <code className="font-mono">ctx.trigger</code> - what woke the
|
||||
run. Which triggers this automation declares is set in the options above.
|
||||
</p>
|
||||
{d.ref && onEditTrigger && (
|
||||
@@ -95,9 +95,9 @@ export default function FlowNodeInspector({
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<select className={inputCls} value={d.path ?? 'input'} disabled={readOnly} onChange={(e) => set({ path: e.target.value as 'input' })}>
|
||||
<option value="input">ctx.input — what was passed in</option>
|
||||
<option value="trigger">ctx.trigger — why this ran</option>
|
||||
<option value="run">ctx.run — this run’s id and start</option>
|
||||
<option value="input">ctx.input - what was passed in</option>
|
||||
<option value="trigger">ctx.trigger - why this ran</option>
|
||||
<option value="run">ctx.run - this run’s id and start</option>
|
||||
</select>
|
||||
{hint}
|
||||
</div>
|
||||
@@ -184,7 +184,7 @@ export default function FlowNodeInspector({
|
||||
className={`${inputCls} font-mono`}
|
||||
value={d.key ?? ''}
|
||||
disabled={readOnly}
|
||||
placeholder="key — or wire one in"
|
||||
placeholder="key - or wire one in"
|
||||
onChange={(e) => set({ key: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
@@ -212,7 +212,7 @@ export default function FlowNodeInspector({
|
||||
{methods.map((m) => (
|
||||
<option key={m.js} value={m.js}>
|
||||
{m.js}
|
||||
{m.sendsTraffic ? ' — sends' : ''}
|
||||
{m.sendsTraffic ? ' - sends' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -554,7 +554,7 @@ export default function ScriptEditor({
|
||||
<>
|
||||
The stored code is not what this canvas produces, so{' '}
|
||||
<code className="font-mono">{manifest.entrypoint ?? 'index.js'}</code> was edited
|
||||
outside Joro. The file is what runs — the canvas is only showing something else.
|
||||
outside Joro. The file is what runs - the canvas is only showing something else.
|
||||
Saving replaces the file with what the canvas produces; Detach, in the rail, keeps
|
||||
the file and drops the canvas.
|
||||
</>
|
||||
@@ -736,7 +736,7 @@ export default function ScriptEditor({
|
||||
<p className="text-[10px] text-semantic-error leading-snug">
|
||||
This failed in the <strong>{NODE_SPECS[failedNode.type].label}</strong> box{' '}
|
||||
<code className="font-mono">{failedNode.id}</code>
|
||||
{view === 'code' ? '.' : ' — it is marked on the canvas.'}
|
||||
{view === 'code' ? '.' : ' - it is marked on the canvas.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -271,7 +271,7 @@ export default function ScriptingPanel() {
|
||||
? 'Disable'
|
||||
: s.paused
|
||||
? 'Enable (clears the pause)'
|
||||
: `Enable${declaredEvents(s.triggers).length ? ` — arms ${declaredEvents(s.triggers).join(', ')}` : ''}`
|
||||
: `Enable${declaredEvents(s.triggers).length ? ` - arms ${declaredEvents(s.triggers).join(', ')}` : ''}`
|
||||
}
|
||||
>
|
||||
{lens ? (
|
||||
@@ -455,14 +455,14 @@ export default function ScriptingPanel() {
|
||||
<h3 className="text-sm font-semibold text-content-primary mb-2">Scripting</h3>
|
||||
<p className="text-[11px] text-content-muted leading-relaxed max-w-xl">
|
||||
Pick something from the rail, or start a new one. An automation is code Joro runs on your
|
||||
behalf — by hand, on a trigger, or when an agent asks for it by id. A lens is an automation
|
||||
behalf - by hand, on a trigger, or when an agent asks for it by id. A lens is an automation
|
||||
that renders a tab in the request viewer instead. A trigger is what wakes one: Joro’s
|
||||
own events fire every time they happen, and a custom trigger adds conditions so it fires on
|
||||
some of them.
|
||||
</p>
|
||||
<p className="text-[10px] text-content-muted leading-relaxed max-w-xl mt-2">
|
||||
Code lives in <code className="font-mono">~/.joro/automations/</code> and never travels
|
||||
inside a project config — what an automation stores with{' '}
|
||||
inside a project config - what an automation stores with{' '}
|
||||
<code className="font-mono">joro.storage</code> does, because that describes one engagement.
|
||||
An automation is always installed disabled; enabling it is what arms its triggers.
|
||||
</p>
|
||||
|
||||
@@ -308,7 +308,7 @@ function Canvas({
|
||||
{!active && (
|
||||
<p className="text-[10px] text-content-muted leading-snug">
|
||||
Click a box to edit it. Drag from a port to wire two together, and drag a box to
|
||||
move it — where you leave it is saved.
|
||||
move it - where you leave it is saved.
|
||||
</p>
|
||||
)}
|
||||
{active?.type === 'condition' && (
|
||||
|
||||
@@ -199,7 +199,7 @@ export default function TriggerConditionFields({
|
||||
{/* A stored value the set does not carry is offered anyway, so opening an
|
||||
older trigger does not silently rewrite it to the first option. */}
|
||||
{!spec!.values!.includes(node.value ?? '') && (
|
||||
<option value={node.value ?? ''}>{node.value || '—'}</option>
|
||||
<option value={node.value ?? ''}>{node.value || '-'}</option>
|
||||
)}
|
||||
{spec!.values!.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
|
||||
@@ -263,7 +263,7 @@ export default function TriggerEditor({
|
||||
|
||||
{t.usedBy.length > 0 && (
|
||||
<p className="text-[10px] text-semantic-warning">
|
||||
Used by {t.usedBy.join(', ')} — a change here changes all of them.
|
||||
Used by {t.usedBy.join(', ')} - a change here changes all of them.
|
||||
</p>
|
||||
)}
|
||||
{t.problem && <p className="text-[10px] text-semantic-error">{t.problem}</p>}
|
||||
@@ -358,7 +358,7 @@ function TestResult({ test }: { test: TriggerTest }) {
|
||||
{test.count === 0 && test.scanned > 0 && (
|
||||
<span className="text-semantic-warning">
|
||||
{' '}
|
||||
Nothing matched — note that a body condition never matches a binary or
|
||||
Nothing matched - note that a body condition never matches a binary or
|
||||
brotli-encoded response.
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { Circle, Link2, Save, Square, Trash2 } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useChainStore } from '../../stores/chainStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { Chain } from '../../lib/chainTypes'
|
||||
|
||||
interface Props {
|
||||
recording: number | null
|
||||
onStartRecord: () => void
|
||||
onStopRecord: () => void
|
||||
onSave: () => void
|
||||
onCorrelate: () => void
|
||||
}
|
||||
|
||||
export default function ChainBar({
|
||||
recording, onStartRecord, onStopRecord, onSave, onCorrelate,
|
||||
}: Props) {
|
||||
const store = useChainStore()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
|
||||
const active = store.active
|
||||
|
||||
async function open(id: string) {
|
||||
try {
|
||||
const chain = await api.chainGet(id)
|
||||
store.setActive(chain)
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(chain: Chain) {
|
||||
try {
|
||||
await api.chainDelete(chain.id)
|
||||
store.setActive(null)
|
||||
const { chains } = await api.chainList()
|
||||
store.setChains(chains)
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-surface-card border-b border-border shrink-0">
|
||||
<select
|
||||
className="bg-surface-input border border-border rounded px-2 py-1 text-xs text-content-primary max-w-56"
|
||||
value={active?.id ?? ''}
|
||||
onChange={(e) => (e.target.value ? open(e.target.value) : store.setActive(null))}
|
||||
>
|
||||
<option value="">Select a chain…</option>
|
||||
{store.chains.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.steps} steps)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{active && (
|
||||
<>
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="bg-surface-input border border-border rounded px-2 py-1 text-xs text-content-primary"
|
||||
value={active.name}
|
||||
onChange={(e) => store.patchActive((c) => ({ ...c, name: e.target.value }))}
|
||||
onBlur={() => setRenaming(false)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="text-xs text-content-primary hover:text-accent-secondary"
|
||||
onClick={() => setRenaming(true)}
|
||||
title="Rename"
|
||||
>
|
||||
{active.name}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[11px] text-content-muted">
|
||||
{active.steps.length} steps · {active.bindings.length} bindings
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{active && (
|
||||
<>
|
||||
<button
|
||||
onClick={onCorrelate}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-surface-input text-content-primary"
|
||||
title="Look for values a later step takes from an earlier response"
|
||||
>
|
||||
<Link2 size={12} /> Correlate
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!store.activeDirty}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent-secondary text-black disabled:opacity-40"
|
||||
>
|
||||
<Save size={12} /> {store.activeDirty ? 'Save' : 'Saved'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(active)}
|
||||
className="text-content-muted hover:text-semantic-error"
|
||||
title="Delete this chain"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recording === null ? (
|
||||
<button
|
||||
onClick={onStartRecord}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-surface-input text-content-primary"
|
||||
title="Mark the capture buffer, browse the workflow, then stop to pick the steps"
|
||||
>
|
||||
<Circle size={12} className="text-semantic-error" /> Record
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStopRecord}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-semantic-error-bg text-content-primary"
|
||||
>
|
||||
<Square size={12} /> Stop recording
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, X } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { b64DecodeUTF8 } from '../../lib/bytes'
|
||||
import type {
|
||||
Chain, ChainBindProposal, ChainBinding, ChainSource, ChainSourceKind,
|
||||
} from '../../lib/chainTypes'
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-surface-card border border-border rounded px-1.5 py-0.5 text-[11px] text-content-primary font-mono'
|
||||
|
||||
interface Props {
|
||||
chain: Chain
|
||||
fromStep: string
|
||||
/** base64 of the bytes the operator selected in the response. */
|
||||
valueB64: string
|
||||
onCancel: () => void
|
||||
onAccept: (bindings: ChainBinding[]) => void
|
||||
}
|
||||
|
||||
/** ChainBinder turns one selected value into bindings.
|
||||
*
|
||||
* Inline rather than a modal, which would cover the request pane showing where
|
||||
* the value lands. */
|
||||
export default function ChainBinder({ chain, fromStep, valueB64, onCancel, onAccept }: Props) {
|
||||
const [proposal, setProposal] = useState<ChainBindProposal | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [varName, setVarName] = useState('')
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set())
|
||||
const [sourceIdx, setSourceIdx] = useState(0)
|
||||
const [custom, setCustom] = useState<ChainSource | null>(null)
|
||||
|
||||
const load = useCallback(
|
||||
async (source?: ChainSource) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const p = await api.chainBindPreview(chain.id, {
|
||||
fromStep,
|
||||
value: valueB64,
|
||||
source,
|
||||
})
|
||||
setProposal(p)
|
||||
if (!source) {
|
||||
setVarName(p.var)
|
||||
// Acceptable targets start ticked: a request carrying a token in both
|
||||
// a header and a form field needs both or it is inconsistent.
|
||||
setPicked(new Set(p.proposed.filter((t) => !t.conflict).map((t) => t.binding.id)))
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message ?? e))
|
||||
setProposal(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[chain.id, fromStep, valueB64],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const label = (id: string) => chain.steps.find((s) => s.id === id)?.label ?? id
|
||||
const sources = proposal?.sources ?? []
|
||||
const chosen = custom ?? sources[sourceIdx]?.source
|
||||
|
||||
function accept() {
|
||||
if (!proposal || !chosen) return
|
||||
const out = proposal.proposed
|
||||
.filter((t) => picked.has(t.binding.id) && !t.conflict)
|
||||
.map((t) => ({ ...t.binding, var: varName || proposal.var, source: chosen }))
|
||||
if (out.length > 0) onAccept(out)
|
||||
}
|
||||
|
||||
const acceptable = proposal?.proposed.filter((t) => picked.has(t.binding.id) && !t.conflict) ?? []
|
||||
|
||||
return (
|
||||
<div className="border border-accent rounded m-2 bg-surface-card shrink-0 max-h-[60%] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border">
|
||||
<span className="text-xs text-content-primary">Bind a value</span>
|
||||
<button className="text-content-muted hover:text-content-primary" onClick={onCancel} aria-label="Cancel">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1.5 space-y-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-content-secondary mb-0.5">Selected</div>
|
||||
<div className="font-mono text-[11px] text-accent-secondary break-all bg-surface-input rounded px-1.5 py-1">
|
||||
{b64DecodeUTF8(valueB64)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{busy && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-content-muted">
|
||||
<Loader2 size={11} className="animate-spin" /> Working out how to read it back…
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-[11px] text-semantic-error">{error}</div>}
|
||||
|
||||
{proposal && (
|
||||
<>
|
||||
<label className="block">
|
||||
<span className="text-[10px] text-content-secondary">Name</span>
|
||||
<input className={inputCls} value={varName} onChange={(e) => setVarName(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-content-secondary mb-0.5">
|
||||
Read it out of the next response by
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{sources.map((s, i) => (
|
||||
<label key={s.detail} className="flex items-start gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5"
|
||||
checked={!custom && sourceIdx === i}
|
||||
onChange={() => {
|
||||
setCustom(null)
|
||||
setSourceIdx(i)
|
||||
}}
|
||||
/>
|
||||
<span className="text-[11px] min-w-0">
|
||||
<span className="font-mono text-content-primary break-all">{s.detail}</span>
|
||||
<SourceProof ok={s.ok} matched={s.matched} err={s.err} />
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-start gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5"
|
||||
checked={!!custom}
|
||||
onChange={() => setCustom({ kind: 'regex', expr: '', group: 1 })}
|
||||
/>
|
||||
<span className="text-[11px] text-content-primary">Custom</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{custom && (
|
||||
<CustomSource
|
||||
source={custom}
|
||||
onChange={(src) => {
|
||||
setCustom(src)
|
||||
void load(src)
|
||||
}}
|
||||
proof={sources[0]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-content-secondary mb-0.5">
|
||||
{proposal.proposed.length > 0
|
||||
? 'Send it in'
|
||||
: 'No later step sends this value.'}
|
||||
</div>
|
||||
{proposal.proposed.length === 0 && (
|
||||
<div className="text-[10px] text-content-muted">
|
||||
Nothing after this step carries these bytes literally. Check the warnings
|
||||
below: a step that sends the value re-encoded cannot take a span, because a
|
||||
binding writes the raw value and the origin would reject the result.
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{proposal.proposed.map((t) => (
|
||||
<label
|
||||
key={t.binding.id}
|
||||
className={`flex items-start gap-1.5 ${t.conflict ? 'opacity-60' : 'cursor-pointer'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
disabled={!!t.conflict}
|
||||
checked={picked.has(t.binding.id)}
|
||||
onChange={() =>
|
||||
setPicked((p) => {
|
||||
const n = new Set(p)
|
||||
if (n.has(t.binding.id)) n.delete(t.binding.id)
|
||||
else n.add(t.binding.id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] min-w-0">
|
||||
<span className="text-content-primary">{label(t.binding.toStep)}</span>{' '}
|
||||
<span className="text-content-muted">
|
||||
· {t.binding.spans.length} place{t.binding.spans.length === 1 ? '' : 's'}
|
||||
{t.note ? ` · ${t.note}` : ''}
|
||||
</span>
|
||||
{t.conflict && (
|
||||
<span className="block text-semantic-error">{t.conflict}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(proposal.warnings ?? []).map((wn) => (
|
||||
<div key={wn} className="flex items-start gap-1 text-[10px] text-semantic-warning">
|
||||
<AlertTriangle size={10} className="shrink-0 mt-0.5" />
|
||||
<span>{wn}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<button
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent-secondary text-black disabled:opacity-40"
|
||||
disabled={acceptable.length === 0}
|
||||
onClick={accept}
|
||||
>
|
||||
<Check size={12} /> Bind{acceptable.length > 1 ? ` ${acceptable.length} steps` : ''}
|
||||
</button>
|
||||
<button className="text-xs text-content-muted hover:text-content-primary" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The readout that turns "this rule looks applicable" into "this rule returns
|
||||
* the bytes you selected". */
|
||||
function SourceProof({ ok, matched, err }: { ok: boolean; matched?: string; err?: string }) {
|
||||
if (err) return <span className="block text-semantic-error">{err}</span>
|
||||
if (ok) return <span className="block text-semantic-success">matches your selection</span>
|
||||
return (
|
||||
<span className="block text-semantic-warning break-all">
|
||||
matches {matched ? `"${matched.slice(0, 60)}"` : 'something else'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const KINDS: ChainSourceKind[] = ['regex', 'between', 'header', 'cookie', 'json']
|
||||
|
||||
function CustomSource({
|
||||
source, onChange, proof,
|
||||
}: {
|
||||
source: ChainSource
|
||||
onChange: (s: ChainSource) => void
|
||||
proof?: { ok: boolean; matched?: string; err?: string }
|
||||
}) {
|
||||
const set = (patch: Partial<ChainSource>) => onChange({ ...source, ...patch })
|
||||
return (
|
||||
<div className="mt-1 pl-5 space-y-1">
|
||||
<select
|
||||
className="bg-surface-card border border-border rounded px-1 py-0.5 text-[10px] text-content-primary"
|
||||
value={source.kind}
|
||||
onChange={(e) => onChange({ kind: e.target.value as ChainSourceKind })}
|
||||
>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{source.kind === 'regex' && (
|
||||
<>
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="pattern with a capture group"
|
||||
value={source.expr ?? ''}
|
||||
onChange={(e) => set({ expr: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min={0}
|
||||
value={source.group ?? 1}
|
||||
onChange={(e) => set({ group: Number(e.target.value) })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{source.kind === 'between' && (
|
||||
<>
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="text before"
|
||||
value={source.prefix ?? ''}
|
||||
onChange={(e) => set({ prefix: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="text after (empty means the rest)"
|
||||
value={source.suffix ?? ''}
|
||||
onChange={(e) => set({ suffix: e.target.value })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(source.kind === 'header' || source.kind === 'cookie') && (
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder={`${source.kind} name`}
|
||||
value={source.name ?? ''}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
{source.kind === 'json' && (
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="dotted path, e.g. data.items[0].id"
|
||||
value={source.path ?? ''}
|
||||
onChange={(e) => set({ path: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
{proof && <SourceProof ok={proof.ok} matched={proof.matched} err={proof.err} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { b64DecodeUTF8 } from '../../lib/bytes'
|
||||
import { cellPill } from '../../lib/chainStatus'
|
||||
import type { ChainResult } from '../../lib/chainTypes'
|
||||
|
||||
interface Props {
|
||||
runId: string
|
||||
index: number | null
|
||||
}
|
||||
|
||||
// Raw bytes are fetched on demand rather than streamed, as sj's and the fuzzer's
|
||||
// detail panes are: a sweep's result rows are numerous and the bytes are large.
|
||||
export default function ChainCellDetail({ runId, index }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const [data, setData] = useState<{ result: ChainResult; reqRaw: string; respRaw: string } | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (index === null) {
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
setError('')
|
||||
api
|
||||
.chainGetResult(runId, index)
|
||||
.then((d) => live && setData(d))
|
||||
.catch((e) => live && setError(String((e as Error).message ?? e)))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [runId, index])
|
||||
|
||||
if (index === null) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-content-muted text-xs px-4 text-center">
|
||||
Select a cell to see the request that was sent and what came back.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return <div className="flex-1 p-3 text-xs text-semantic-error">{error}</div>
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="flex-1 p-3 text-xs text-content-muted">Loading…</div>
|
||||
}
|
||||
|
||||
const r = data.result
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{cellPill(r.cell, r.status, r.note)}
|
||||
<span className="text-content-primary text-xs">{r.label}</span>
|
||||
{r.occurrence > 0 && (
|
||||
<span className="text-[10px] text-content-muted">occurrence {r.occurrence + 1}</span>
|
||||
)}
|
||||
{r.seq ? (
|
||||
<button
|
||||
className="text-[10px] text-accent-secondary flex items-center gap-1"
|
||||
onClick={() => navigate('/history', { state: { focusRequestId: r.requestId } })}
|
||||
>
|
||||
<ExternalLink size={10} /> seq {r.seq}
|
||||
</button>
|
||||
) : (
|
||||
r.seqNote && <span className="text-[10px] text-content-muted">{r.seqNote}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-content-muted mt-1 font-mono truncate">{r.url}</div>
|
||||
{r.durationMs > 0 && (
|
||||
<div className="text-[10px] text-content-muted mt-0.5">
|
||||
{r.durationMs}ms · {r.len} bytes
|
||||
{r.shash && ` · struct ${r.shash}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{r.cell === 'unresolved' && r.missing && (
|
||||
<div className="px-3 py-2 bg-semantic-error-bg/40 border-b border-border text-xs shrink-0">
|
||||
<div className="text-content-primary mb-1">This step did not send.</div>
|
||||
<div className="text-content-secondary">
|
||||
It needs{' '}
|
||||
{r.missing.map((m, i) => (
|
||||
<span key={m.var}>
|
||||
{i > 0 && ', '}
|
||||
<span className="font-mono text-accent-secondary">{m.var}</span>
|
||||
</span>
|
||||
))}
|
||||
, which nothing produced in this ordering. That says nothing about the application -
|
||||
set the binding to send its recorded value if replaying a stale one is the test.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.error && (
|
||||
<div className="px-3 py-2 text-xs text-semantic-error border-b border-border shrink-0">
|
||||
{r.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!r.captured?.length && (
|
||||
<div className="px-3 py-2 text-xs border-b border-border shrink-0">
|
||||
<div className="text-semantic-warning mb-0.5">Values this step did not produce:</div>
|
||||
{r.captured.map((c) => (
|
||||
<div key={c} className="text-content-muted font-mono text-[10px]">
|
||||
{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 grid grid-rows-2">
|
||||
<div className="min-h-0 flex flex-col border-b border-border">
|
||||
<div className="px-3 py-1 text-[10px] text-content-secondary shrink-0">Request sent</div>
|
||||
<pre className="flex-1 min-h-0 overflow-auto px-3 pb-2 font-mono text-[11px] text-content-primary whitespace-pre-wrap break-all">
|
||||
{data.reqRaw ? b64DecodeUTF8(data.reqRaw) : '(not sent)'}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="min-h-0 flex flex-col">
|
||||
<div className="px-3 py-1 text-[10px] text-content-secondary shrink-0">Response</div>
|
||||
<pre className="flex-1 min-h-0 overflow-auto px-3 pb-2 font-mono text-[11px] text-content-primary whitespace-pre-wrap break-all">
|
||||
{data.respRaw ? b64DecodeUTF8(data.respRaw) : '(no response)'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Flag, RotateCcw } from 'lucide-react'
|
||||
import { cellPill, isFinding, variantKindLabel, verdictPill } from '../../lib/chainStatus'
|
||||
import type { ChainRun } from '../../stores/chainStore'
|
||||
|
||||
interface Props {
|
||||
run: ChainRun
|
||||
onSelect: (index: number) => void
|
||||
selectedIndex: number | null
|
||||
}
|
||||
|
||||
// The grid is variants down, steps across.
|
||||
//
|
||||
// Slots are addressed arithmetically — variantIndex*stride + stepIndex — which is
|
||||
// the shape the server writes them in, so a cell that never ran is a hole rather
|
||||
// than a missing row, and reads as "not run" instead of silently shifting every
|
||||
// cell after it left.
|
||||
export default function ChainGrid({ run, onSelect, selectedIndex }: Props) {
|
||||
if (run.steps.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-content-muted text-sm">
|
||||
Waiting for the run to start…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<table className="text-xs border-collapse">
|
||||
<thead className="sticky top-0 bg-surface-card z-10">
|
||||
<tr>
|
||||
<th className="text-left px-2 py-1.5 border-b border-border font-normal text-content-secondary sticky left-0 bg-surface-card min-w-56">
|
||||
Variant
|
||||
</th>
|
||||
{run.steps.map((s) => (
|
||||
<th
|
||||
key={s.id}
|
||||
className="px-2 py-1.5 border-b border-border font-normal text-content-secondary whitespace-nowrap"
|
||||
title={s.label}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{s.setup && <RotateCcw size={10} className="text-semantic-info" />}
|
||||
{s.goal && <Flag size={10} className="text-accent-secondary" />}
|
||||
<span className="truncate max-w-32 inline-block">{s.label}</span>
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-left px-2 py-1.5 border-b border-border font-normal text-content-secondary">
|
||||
Verdict
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.variants.map((v, vi) => {
|
||||
const verdict = run.verdicts[v.id]
|
||||
return (
|
||||
<tr
|
||||
key={v.id}
|
||||
className={isFinding(verdict?.verdict) ? 'bg-semantic-error-bg/40' : ''}
|
||||
>
|
||||
<td className="px-2 py-1 border-b border-border-subtle sticky left-0 bg-surface-body">
|
||||
<span className="text-content-muted mr-1.5">{variantKindLabel(v.kind)}</span>
|
||||
<span className="text-content-primary">{v.label}</span>
|
||||
</td>
|
||||
{run.steps.map((s, si) => {
|
||||
const idx = vi * (run.stride || run.steps.length) + si
|
||||
const cell = run.results[idx]
|
||||
return (
|
||||
<td
|
||||
key={s.id}
|
||||
className={`px-2 py-1 border-b border-border-subtle text-center cursor-pointer hover:bg-surface-hover ${
|
||||
selectedIndex === idx ? 'bg-surface-hover' : ''
|
||||
}`}
|
||||
onClick={() => cell?.cell && onSelect(idx)}
|
||||
>
|
||||
{cellPill(cell?.cell, cell?.status ?? 0, cell?.note)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="px-2 py-1 border-b border-border-subtle whitespace-nowrap">
|
||||
{verdictPill(verdict?.verdict, verdict?.detail)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AlertTriangle, Link2, Rows2, Rows3 } from 'lucide-react'
|
||||
import { beginPointerDrag } from '../../lib/pointerDrag'
|
||||
import {
|
||||
PORT_DY,
|
||||
arcPath,
|
||||
backwardsAfterMove,
|
||||
backwardsIn,
|
||||
laneX,
|
||||
layoutArcs,
|
||||
legalRange,
|
||||
moveStep,
|
||||
} from '../../lib/chainMap'
|
||||
import type { Chain, ChainBinding } from '../../lib/chainTypes'
|
||||
import ChainStepRow from './ChainStepRow'
|
||||
|
||||
/** How close to an edge the pointer must get before the list scrolls itself. */
|
||||
const EDGE = 40
|
||||
const EDGE_SPEED = 8
|
||||
|
||||
interface Props {
|
||||
chain: Chain
|
||||
selectedStepId: string | null
|
||||
selectedBindingId: string | null
|
||||
onSelect: (stepId: string | null, bindingId?: string | null) => void
|
||||
onChange: (chain: Chain) => void
|
||||
onCorrelate: () => void
|
||||
}
|
||||
|
||||
type Hover = { kind: 'step'; id: string } | { kind: 'arc'; id: string } | null
|
||||
|
||||
interface DragState {
|
||||
from: number
|
||||
to: number
|
||||
dy: number
|
||||
/** Row geometry as it was when the drag began; never re-measured mid-gesture,
|
||||
* because the rows are being transformed and would measure their own shift. */
|
||||
tops: number[]
|
||||
heights: number[]
|
||||
}
|
||||
|
||||
export default function ChainMap({
|
||||
chain, selectedStepId, selectedBindingId, onSelect, onChange, onCorrelate,
|
||||
}: Props) {
|
||||
const uid = useId().replace(/:/g, '')
|
||||
const goalId = chain.goalStepId || chain.steps[chain.steps.length - 1]?.id
|
||||
|
||||
const [compact, setCompact] = useState(false)
|
||||
const [hover, setHover] = useState<Hover>(null)
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
const [announce, setAnnounce] = useState('')
|
||||
const [tops, setTops] = useState<number[]>([])
|
||||
const [listH, setListH] = useState(0)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const rowRefs = useRef(new Map<string, HTMLLIElement>())
|
||||
const dragging = useRef(false)
|
||||
|
||||
const { arcs, gutterW } = useMemo(() => layoutArcs(chain), [chain])
|
||||
|
||||
const index = useMemo(() => {
|
||||
const m: Record<string, number> = {}
|
||||
chain.steps.forEach((s, i) => (m[s.id] = i))
|
||||
return m
|
||||
}, [chain.steps])
|
||||
|
||||
// Measured, not assumed: a row is one or two lines tall depending on its URL.
|
||||
const measure = useCallback(() => {
|
||||
if (dragging.current) return
|
||||
const wrap = wrapRef.current
|
||||
if (!wrap) return
|
||||
const base = wrap.getBoundingClientRect().top
|
||||
const next = chain.steps.map((s) => {
|
||||
const el = rowRefs.current.get(s.id)
|
||||
return el ? el.getBoundingClientRect().top - base : 0
|
||||
})
|
||||
setTops(next)
|
||||
setListH(wrap.getBoundingClientRect().height)
|
||||
}, [chain.steps])
|
||||
|
||||
useLayoutEffect(measure, [measure, compact])
|
||||
|
||||
useEffect(() => {
|
||||
const wrap = wrapRef.current
|
||||
if (!wrap || typeof ResizeObserver === 'undefined') return
|
||||
const ro = new ResizeObserver(() => measure())
|
||||
ro.observe(wrap)
|
||||
return () => ro.disconnect()
|
||||
}, [measure])
|
||||
|
||||
const backwards = useMemo(() => backwardsIn(chain), [chain])
|
||||
const previewBad = useMemo(
|
||||
() => (drag ? new Set(backwardsAfterMove(chain, drag.from, drag.to).map((b) => b.id)) : null),
|
||||
[chain, drag],
|
||||
)
|
||||
|
||||
const patch = useCallback((c: Chain) => onChange(c), [onChange])
|
||||
|
||||
const removeStep = useCallback(
|
||||
(id: string) =>
|
||||
patch({
|
||||
...chain,
|
||||
steps: chain.steps.filter((s) => s.id !== id),
|
||||
// Bindings at either end of a removed step have nowhere to go. Dropping
|
||||
// them here rather than letting the server refuse the save is what keeps
|
||||
// the delete a single click.
|
||||
bindings: chain.bindings.filter((b) => b.fromStep !== id && b.toStep !== id),
|
||||
goalStepId: chain.goalStepId === id ? '' : chain.goalStepId,
|
||||
}),
|
||||
[chain, patch],
|
||||
)
|
||||
|
||||
const removeBinding = useCallback(
|
||||
(id: string) => patch({ ...chain, bindings: chain.bindings.filter((b) => b.id !== id) }),
|
||||
[chain, patch],
|
||||
)
|
||||
|
||||
const commitMove = useCallback(
|
||||
(from: number, to: number) => {
|
||||
if (from === to) return
|
||||
const moved = chain.steps[from]
|
||||
const next = moveStep(chain, from, to)
|
||||
patch(next)
|
||||
const bad = backwardsIn(next).length
|
||||
setAnnounce(
|
||||
`Moved ${moved.label} to position ${to + 1} of ${chain.steps.length}.` +
|
||||
(bad ? ` ${bad} dependenc${bad === 1 ? 'y' : 'ies'} now point backwards.` : ''),
|
||||
)
|
||||
},
|
||||
[chain, patch],
|
||||
)
|
||||
|
||||
// The drag is the edit. Rows slide out of the way as the pointer moves, so
|
||||
// the destination is visible before the drop.
|
||||
const onGripDown = useCallback(
|
||||
(e: React.PointerEvent, from: number) => {
|
||||
const wrap = wrapRef.current
|
||||
const scroll = scrollRef.current
|
||||
if (!wrap || !scroll) return
|
||||
const base = wrap.getBoundingClientRect().top
|
||||
const snapTops: number[] = []
|
||||
const snapH: number[] = []
|
||||
for (const s of chain.steps) {
|
||||
const el = rowRefs.current.get(s.id)
|
||||
const r = el?.getBoundingClientRect()
|
||||
snapTops.push(r ? r.top - base : 0)
|
||||
snapH.push(r ? r.height : 0)
|
||||
}
|
||||
const startY = e.clientY
|
||||
const startScroll = scroll.scrollTop
|
||||
let clientY = e.clientY
|
||||
let raf = 0
|
||||
|
||||
const update = () => {
|
||||
const dy = clientY - startY + (scroll.scrollTop - startScroll)
|
||||
const centre = snapTops[from] + dy + snapH[from] / 2
|
||||
let to = from
|
||||
for (let i = 0; i < chain.steps.length; i++) {
|
||||
if (i === from) continue
|
||||
const c = snapTops[i] + snapH[i] / 2
|
||||
if (i < from && centre < c) to = Math.min(to, i)
|
||||
if (i > from && centre > c) to = Math.max(to, i)
|
||||
}
|
||||
setDrag({ from, to, dy, tops: snapTops, heights: snapH })
|
||||
}
|
||||
|
||||
// beginPointerDrag's shield takes the pointer, so the container never
|
||||
// sees a drag near its edge and a long chain cannot be reached.
|
||||
const tick = () => {
|
||||
const r = scroll.getBoundingClientRect()
|
||||
if (clientY < r.top + EDGE) scroll.scrollTop -= EDGE_SPEED
|
||||
else if (clientY > r.bottom - EDGE) scroll.scrollTop += EDGE_SPEED
|
||||
else {
|
||||
raf = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
update()
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
dragging.current = true
|
||||
update()
|
||||
raf = requestAnimationFrame(tick)
|
||||
|
||||
const stop = beginPointerDrag(
|
||||
e,
|
||||
'grabbing',
|
||||
(ev) => {
|
||||
clientY = ev.clientY
|
||||
update()
|
||||
},
|
||||
() => {
|
||||
cancelAnimationFrame(raf)
|
||||
dragging.current = false
|
||||
setDrag((d) => {
|
||||
if (d) commitMove(d.from, d.to)
|
||||
return null
|
||||
})
|
||||
},
|
||||
)
|
||||
if (!stop) {
|
||||
cancelAnimationFrame(raf)
|
||||
dragging.current = false
|
||||
setDrag(null)
|
||||
}
|
||||
},
|
||||
[chain.steps, commitMove],
|
||||
)
|
||||
|
||||
const onGripKey = useCallback(
|
||||
(e: React.KeyboardEvent, i: number) => {
|
||||
if (!e.altKey) return
|
||||
const to = e.key === 'ArrowUp' ? i - 1 : e.key === 'ArrowDown' ? i + 1 : -1
|
||||
if (to < 0 || to >= chain.steps.length) return
|
||||
e.preventDefault()
|
||||
const id = chain.steps[i].id
|
||||
commitMove(i, to)
|
||||
// Follow the row, or the next keystroke moves whatever took its slot.
|
||||
requestAnimationFrame(() => rowRefs.current.get(id)?.querySelector('button')?.focus())
|
||||
},
|
||||
[chain.steps, commitMove],
|
||||
)
|
||||
|
||||
// Which arcs and rows are lit. Hovering a step lights everything it is joined
|
||||
// to; hovering an arc lights both of its ends.
|
||||
const hot = useMemo(() => {
|
||||
const arcIds = new Set<string>()
|
||||
const stepIds = new Set<string>()
|
||||
if (!hover && !selectedBindingId) return null
|
||||
if (selectedBindingId) {
|
||||
const b = chain.bindings.find((x) => x.id === selectedBindingId)
|
||||
if (b) {
|
||||
arcIds.add(b.id)
|
||||
stepIds.add(b.fromStep)
|
||||
stepIds.add(b.toStep)
|
||||
}
|
||||
}
|
||||
if (hover?.kind === 'step') {
|
||||
stepIds.add(hover.id)
|
||||
for (const b of chain.bindings) {
|
||||
if (b.fromStep === hover.id || b.toStep === hover.id) {
|
||||
arcIds.add(b.id)
|
||||
stepIds.add(b.fromStep)
|
||||
stepIds.add(b.toStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hover?.kind === 'arc') {
|
||||
const b = chain.bindings.find((x) => x.id === hover.id)
|
||||
if (b) {
|
||||
arcIds.add(b.id)
|
||||
stepIds.add(b.fromStep)
|
||||
stepIds.add(b.toStep)
|
||||
}
|
||||
}
|
||||
return { arcIds, stepIds }
|
||||
}, [hover, selectedBindingId, chain.bindings])
|
||||
|
||||
const band = drag ? legalRange(chain, drag.from) : null
|
||||
const shift = (i: number) => {
|
||||
if (!drag) return 0
|
||||
if (i === drag.from) return drag.dy
|
||||
const h = drag.heights[drag.from]
|
||||
if (drag.to > drag.from && i > drag.from && i <= drag.to) return -h
|
||||
if (drag.to < drag.from && i >= drag.to && i < drag.from) return h
|
||||
return 0
|
||||
}
|
||||
|
||||
const y = (i: number) => (tops[i] ?? 0) + PORT_DY
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{backwards.length > 0 && (
|
||||
<div className="flex items-start gap-2 px-3 py-1.5 bg-semantic-error-bg border-b border-border shrink-0">
|
||||
<AlertTriangle size={13} className="text-semantic-error shrink-0 mt-0.5" />
|
||||
<div className="text-[11px] text-content-primary">
|
||||
{backwards.length} dependenc{backwards.length === 1 ? 'y' : 'ies'} point backwards -
|
||||
the value is produced after the step that needs it, so the save will be refused.
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{backwards.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
className="font-mono text-[10px] px-1 py-px rounded bg-surface-input border border-border hover:text-accent-secondary"
|
||||
onClick={() => onSelect(b.toStep, b.id)}
|
||||
onMouseEnter={() => setHover({ kind: 'arc', id: b.id })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{b.var} · {chain.steps[index[b.fromStep]]?.label ?? '?'} →{' '}
|
||||
{chain.steps[index[b.toStep]]?.label ?? '?'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border shrink-0">
|
||||
<span className="text-[10px] text-content-muted">
|
||||
Order runs top to bottom. Arcs are the values one response hands a later request.
|
||||
</span>
|
||||
<button
|
||||
className="text-content-muted hover:text-content-primary"
|
||||
title={compact ? 'Show full detail' : 'Compact rows'}
|
||||
aria-label={compact ? 'Show full detail' : 'Compact rows'}
|
||||
onClick={() => setCompact((c) => !c)}
|
||||
>
|
||||
{compact ? <Rows3 size={13} /> : <Rows2 size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Background clicks only: a row's own click bubbles here, and an
|
||||
unguarded handler would deselect the step it just selected. */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto"
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('li')) onSelect(null)
|
||||
}}
|
||||
>
|
||||
<div ref={wrapRef} className="relative px-2 py-2">
|
||||
<svg
|
||||
// top-0, not top-2: row tops are measured from the wrapper's border
|
||||
// box, so the arc layer has to share that origin or every arc sits
|
||||
// one padding step above the row it points at.
|
||||
className="absolute left-2 top-0 pointer-events-none overflow-visible"
|
||||
width={gutterW}
|
||||
height={listH}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<marker id={`${uid}-tip`} viewBox="0 0 6 6" refX="5" refY="3" markerWidth="5" markerHeight="5" orient="auto">
|
||||
<path d="M0 0 L6 3 L0 6 z" fill="var(--color-accent-secondary)" />
|
||||
</marker>
|
||||
<marker id={`${uid}-tip-dim`} viewBox="0 0 6 6" refX="5" refY="3" markerWidth="5" markerHeight="5" orient="auto">
|
||||
<path d="M0 0 L6 3 L0 6 z" fill="var(--color-border)" />
|
||||
</marker>
|
||||
<marker id={`${uid}-tip-bad`} viewBox="0 0 6 6" refX="5" refY="3" markerWidth="5" markerHeight="5" orient="auto">
|
||||
<path d="M0 0 L6 3 L0 6 z" fill="var(--color-semantic-error)" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{band && drag && tops.length > 0 && (
|
||||
<rect
|
||||
x={0}
|
||||
y={(tops[band[0]] ?? 0) - 2}
|
||||
width={gutterW}
|
||||
height={
|
||||
(tops[band[1]] ?? tops[tops.length - 1] ?? 0) +
|
||||
(drag.heights[band[1]] ?? 0) -
|
||||
(tops[band[0]] ?? 0) +
|
||||
4
|
||||
}
|
||||
fill="var(--color-semantic-info)"
|
||||
opacity="0.12"
|
||||
/>
|
||||
)}
|
||||
|
||||
{tops.length === chain.steps.length &&
|
||||
arcs.map((a) => {
|
||||
const bad = previewBad ? previewBad.has(a.id) : a.backwards
|
||||
const lit = !hot || hot.arcIds.has(a.id)
|
||||
const x = laneX(a.depth, gutterW)
|
||||
const d = arcPath(x, y(a.from), y(a.to), gutterW)
|
||||
const stroke = bad
|
||||
? 'var(--color-semantic-error)'
|
||||
: hot?.arcIds.has(a.id)
|
||||
? 'var(--color-accent-secondary)'
|
||||
: 'var(--color-border)'
|
||||
const tip = bad ? 'tip-bad' : hot?.arcIds.has(a.id) ? 'tip' : 'tip-dim'
|
||||
return (
|
||||
<g key={a.id} className="joro-chain-arc" opacity={lit ? 1 : 0.3}>
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={12}
|
||||
style={{ pointerEvents: 'stroke' }}
|
||||
onMouseEnter={() => setHover({ kind: 'arc', id: a.id })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSelect(a.binding.toStep, a.id)
|
||||
}}
|
||||
>
|
||||
<title>
|
||||
{bad
|
||||
? `${a.binding.var} is produced after the step that needs it, so it can never resolve`
|
||||
: `${a.binding.var} flows from step ${a.from + 1} into step ${a.to + 1}`}
|
||||
</title>
|
||||
</path>
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={hot?.arcIds.has(a.id) ? 2 : 1.25}
|
||||
strokeDasharray={bad ? '4 3' : undefined}
|
||||
markerEnd={`url(#${uid}-${tip})`}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
<circle cx={gutterW - 1} cy={y(a.from)} r={2.5} fill={stroke} style={{ pointerEvents: 'none' }} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<ol style={{ paddingLeft: gutterW }}>
|
||||
{chain.steps.map((step, i) => {
|
||||
const consumes = chain.bindings
|
||||
.filter((b) => b.toStep === step.id)
|
||||
.map((b) => ({ binding: b, peer: index[b.fromStep] ?? 0 }))
|
||||
const produces = chain.bindings
|
||||
.filter((b) => b.fromStep === step.id)
|
||||
.map((b) => ({ binding: b, peer: index[b.toStep] ?? 0 }))
|
||||
const dy = shift(i)
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
style={{
|
||||
transform: dy ? `translateY(${dy}px)` : undefined,
|
||||
transition: drag && i !== drag.from ? 'transform 100ms' : undefined,
|
||||
}}
|
||||
onMouseEnter={() => setHover({ kind: 'step', id: step.id })}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
<ChainStepRow
|
||||
ref={(el) => {
|
||||
if (el) rowRefs.current.set(step.id, el)
|
||||
else rowRefs.current.delete(step.id)
|
||||
}}
|
||||
step={step}
|
||||
index={i}
|
||||
total={chain.steps.length}
|
||||
selected={selectedStepId === step.id}
|
||||
goal={step.id === goalId}
|
||||
lifted={drag?.from === i}
|
||||
highlighted={!!hot?.stepIds.has(step.id)}
|
||||
compact={compact}
|
||||
consumes={consumes}
|
||||
produces={produces}
|
||||
onSelect={() => onSelect(step.id)}
|
||||
onToggleSetup={() =>
|
||||
patch({
|
||||
...chain,
|
||||
steps: chain.steps.map((s) =>
|
||||
s.id === step.id ? { ...s, setup: !s.setup } : s,
|
||||
),
|
||||
})
|
||||
}
|
||||
onSetGoal={() => patch({ ...chain, goalStepId: step.id })}
|
||||
onRemove={() => removeStep(step.id)}
|
||||
onRemoveBinding={removeBinding}
|
||||
onSelectBinding={(b: ChainBinding) => onSelect(b.toStep, b.id)}
|
||||
onHoverBinding={(id) => setHover(id ? { kind: 'arc', id } : null)}
|
||||
onGripDown={(e) => onGripDown(e, i)}
|
||||
onGripKey={(e) => onGripKey(e, i)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{drag && previewBad && previewBad.size > 0 && (
|
||||
<div
|
||||
className="absolute right-2 text-[10px] text-semantic-warning bg-surface-card border border-border rounded px-1.5 py-0.5"
|
||||
style={{ top: (drag.tops[drag.from] ?? 0) + drag.dy }}
|
||||
>
|
||||
{previewBad.size} dependenc{previewBad.size === 1 ? 'y' : 'ies'} would point backwards
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chain.steps.length === 1 && (
|
||||
<div className="px-3 py-2 border-t border-border text-[11px] text-content-muted shrink-0">
|
||||
A chain needs a second step before there is an order to test.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chain.steps.length > 1 && chain.bindings.length === 0 && (
|
||||
<div className="px-3 py-2 border-t border-border shrink-0">
|
||||
<div className="text-xs text-content-primary">Nothing flows between these steps yet.</div>
|
||||
<div className="text-[11px] text-content-muted mt-0.5 leading-relaxed">
|
||||
Every step replays the tokens it was recorded with, so a variant that fails may only
|
||||
mean the session went stale rather than that the application enforced its order.
|
||||
</div>
|
||||
<button
|
||||
onClick={onCorrelate}
|
||||
className="mt-1.5 flex items-center gap-1 px-2 py-1 rounded text-xs bg-surface-input text-content-primary hover:text-accent-secondary"
|
||||
>
|
||||
<Link2 size={12} /> Correlate - find the values one response hands the next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { b64ToBytes, bytesToB64, formatBytes, HEX_DUMP_LIMIT } from '../../lib/bytes'
|
||||
|
||||
// A byte-addressed view of a recorded message.
|
||||
//
|
||||
// Not b64DecodeUTF8: it decodes with a non-fatal TextDecoder, so invalid bytes
|
||||
// become U+FFFD and its atob fallback almost never runs — leaving a char-to-byte
|
||||
// mapping that depends on the content. Right for prose, wrong for offsets.
|
||||
//
|
||||
// So the mode is picked once per message, from the bytes, and char index maps to
|
||||
// byte index either way: fatal UTF-8 if it decodes, else latin-1 via atob, one
|
||||
// char per byte. Spans then cut the byte array and each piece renders however
|
||||
// reads best, because boundaries are never recovered from the rendered string.
|
||||
|
||||
/** RawMark is one highlighted byte range. */
|
||||
export interface RawMark {
|
||||
start: number
|
||||
end: number
|
||||
id: string
|
||||
label: string
|
||||
tone: 'bound' | 'proposed' | 'conflict'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** base64 of the bytes being shown. */
|
||||
raw: string
|
||||
marks?: RawMark[]
|
||||
/** Dims every mark but this one — the inspector row under the pointer. */
|
||||
activeId?: string | null
|
||||
onSelect?: (sel: { start: number; end: number; valueB64: string }) => void
|
||||
onMarkClick?: (id: string) => void
|
||||
/** From the decode endpoint: "gzip", or "br (not decoded)". */
|
||||
decodedNote?: string
|
||||
maxBytes?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const toneClass: Record<RawMark['tone'], string> = {
|
||||
// Only semantic-error has a -bg token, so tone rides on the underline.
|
||||
bound: 'bg-surface-hover border-b-2 border-accent-secondary',
|
||||
proposed: 'bg-surface-hover border-b-2 border-semantic-warning',
|
||||
conflict: 'bg-surface-hover border-b-2 border-semantic-error text-semantic-error',
|
||||
}
|
||||
|
||||
interface Piece {
|
||||
start: number
|
||||
text: string
|
||||
mark?: RawMark
|
||||
}
|
||||
|
||||
export default function ChainRawPane({
|
||||
raw, marks = [], activeId, onSelect, onMarkClick, decodedNote, maxBytes = HEX_DUMP_LIMIT, className = '',
|
||||
}: Props) {
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
const { bytes, utf8 } = useMemo(() => {
|
||||
const all = b64ToBytes(raw)
|
||||
let ok = true
|
||||
try {
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(all)
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
return { bytes: all, utf8: ok }
|
||||
}, [raw])
|
||||
|
||||
const render = useCallback(
|
||||
(b: Uint8Array) => {
|
||||
if (utf8) return new TextDecoder().decode(b)
|
||||
let s = ''
|
||||
for (const c of b) {
|
||||
s += c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) ? String.fromCharCode(c) : '·'
|
||||
}
|
||||
return s
|
||||
},
|
||||
[utf8],
|
||||
)
|
||||
|
||||
// A step may hold a megabyte, so a mark past the cap pulls the window to
|
||||
// itself: "3 places" must never disagree with what is drawn.
|
||||
const { from, to, clipped } = useMemo(() => {
|
||||
if (bytes.length <= maxBytes) return { from: 0, to: bytes.length, clipped: false }
|
||||
const far = marks.filter((m) => m.start >= maxBytes)
|
||||
if (far.length === 0) return { from: 0, to: maxBytes, clipped: true }
|
||||
const first = Math.min(...far.map((m) => m.start))
|
||||
const start = Math.max(0, first - Math.floor(maxBytes / 4))
|
||||
return { from: start, to: Math.min(bytes.length, start + maxBytes), clipped: true }
|
||||
}, [bytes.length, maxBytes, marks])
|
||||
|
||||
const pieces = useMemo(() => {
|
||||
const inWindow = marks
|
||||
.filter((m) => m.end > from && m.start < to && m.start < m.end)
|
||||
.sort((a, b) => a.start - b.start)
|
||||
const out: Piece[] = []
|
||||
let at = from
|
||||
for (const m of inWindow) {
|
||||
const s = Math.max(at, m.start)
|
||||
const e = Math.min(to, m.end)
|
||||
if (s >= e) continue
|
||||
if (s > at) out.push({ start: at, text: render(bytes.subarray(at, s)) })
|
||||
out.push({ start: s, text: render(bytes.subarray(s, e)), mark: m })
|
||||
at = e
|
||||
}
|
||||
if (at < to) out.push({ start: at, text: render(bytes.subarray(at, to)) })
|
||||
return out
|
||||
}, [marks, from, to, bytes, render])
|
||||
|
||||
// Each piece carries its own byte start, so only the offset within it needs
|
||||
// converting — the one place the mode rule is applied.
|
||||
const charToByte = useCallback(
|
||||
(text: string, charOffset: number) =>
|
||||
utf8 ? new TextEncoder().encode(text.slice(0, charOffset)).length : charOffset,
|
||||
[utf8],
|
||||
)
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
if (!onSelect) return
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.isCollapsed || !preRef.current) return
|
||||
const range = sel.getRangeAt(0)
|
||||
if (!preRef.current.contains(range.commonAncestorContainer)) return
|
||||
|
||||
const off = (node: Node, offset: number): number | null => {
|
||||
let el: HTMLElement | null =
|
||||
node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement)
|
||||
while (el && el.dataset.off === undefined) el = el.parentElement
|
||||
if (!el) return null
|
||||
return Number(el.dataset.off) + charToByte(el.textContent ?? '', offset)
|
||||
}
|
||||
const a = off(range.startContainer, range.startOffset)
|
||||
const b = off(range.endContainer, range.endOffset)
|
||||
if (a === null || b === null) return
|
||||
const [start, end] = a <= b ? [a, b] : [b, a]
|
||||
if (start >= end) return
|
||||
onSelect({ start, end, valueB64: bytesToB64(bytes.subarray(start, end)) })
|
||||
}, [onSelect, charToByte, bytes])
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col min-h-0 ${className}`}>
|
||||
{(decodedNote || clipped) && (
|
||||
<div className="text-[10px] text-content-muted px-1 pb-1 flex items-center gap-2 shrink-0">
|
||||
{decodedNote && <span>decoded: {decodedNote}</span>}
|
||||
{clipped && (
|
||||
<span>
|
||||
showing {formatBytes(to - from)} of {formatBytes(bytes.length)}
|
||||
</span>
|
||||
)}
|
||||
{!utf8 && <span>not UTF-8 - shown byte for byte</span>}
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
ref={preRef}
|
||||
onMouseUp={handleSelect}
|
||||
className="flex-1 min-h-0 overflow-auto bg-surface-input border border-border rounded px-2 py-1 font-mono text-[11px] text-content-primary whitespace-pre-wrap break-all select-text"
|
||||
>
|
||||
{from > 0 && <span className="text-content-muted">… {formatBytes(from)} earlier …{'\n'}</span>}
|
||||
{pieces.map((p) =>
|
||||
p.mark ? (
|
||||
<span
|
||||
key={p.start}
|
||||
data-off={p.start}
|
||||
className={`${toneClass[p.mark.tone]} ${
|
||||
activeId && activeId !== p.mark.id ? 'opacity-40' : ''
|
||||
} cursor-pointer`}
|
||||
title={p.mark.label}
|
||||
onClick={() => onMarkClick?.(p.mark!.id)}
|
||||
>
|
||||
{p.text}
|
||||
</span>
|
||||
) : (
|
||||
<span key={p.start} data-off={p.start}>
|
||||
{p.text}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
{to < bytes.length && (
|
||||
<span className="text-content-muted">{'\n'}… {formatBytes(bytes.length - to)} more …</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { methodClass } from '../../lib/chainStatus'
|
||||
import type { ChainRecordCandidate } from '../../lib/chainTypes'
|
||||
|
||||
interface Props {
|
||||
candidates: ChainRecordCandidate[]
|
||||
onClose: () => void
|
||||
onCreate: (seqs: number[], name: string) => void
|
||||
}
|
||||
|
||||
// Recording hands back candidates to trim rather than building a chain outright.
|
||||
//
|
||||
// A workflow's traffic is mostly not the workflow: analytics beacons, fonts,
|
||||
// polling. A chain built from an unreviewed window would be almost entirely
|
||||
// noise, and the trimming is faster here than deleting steps from a map.
|
||||
export default function ChainRecordModal({ candidates, onClose, onCreate }: Props) {
|
||||
const [picked, setPicked] = useState<Set<number>>(
|
||||
// Seed with the requests that plausibly are the workflow: anything that
|
||||
// changes state, plus documents and API responses. Everything else is one
|
||||
// click away.
|
||||
() => new Set(candidates.filter(isLikelyStep).map((c) => c.seq)),
|
||||
)
|
||||
const [name, setName] = useState('chain')
|
||||
|
||||
const toggle = (seq: number) =>
|
||||
setPicked((p) => {
|
||||
const next = new Set(p)
|
||||
if (next.has(seq)) next.delete(seq)
|
||||
else next.add(seq)
|
||||
return next
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-surface-card border border-border rounded w-[46rem] max-h-[80vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<span className="text-sm text-content-primary">
|
||||
Pick the steps of this workflow
|
||||
</span>
|
||||
<button onClick={onClose} className="text-content-muted hover:text-content-primary">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
||||
<label className="text-xs text-content-secondary">Name</label>
|
||||
<input
|
||||
className="bg-surface-input border border-border rounded px-2 py-1 text-xs text-content-primary flex-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<span className="text-[11px] text-content-muted">{picked.size} selected</span>
|
||||
<button
|
||||
className="text-[11px] text-accent-secondary"
|
||||
onClick={() => setPicked(new Set(candidates.map((c) => c.seq)))}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
<button className="text-[11px] text-accent-secondary" onClick={() => setPicked(new Set())}>
|
||||
none
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{candidates.length === 0 && (
|
||||
<div className="p-4 text-xs text-content-muted">
|
||||
Nothing was captured while recording.
|
||||
</div>
|
||||
)}
|
||||
{candidates.map((c) => (
|
||||
<label
|
||||
key={c.seq}
|
||||
className="flex items-center gap-2 px-3 py-1 hover:bg-surface-hover cursor-pointer border-b border-border-subtle"
|
||||
>
|
||||
<input type="checkbox" checked={picked.has(c.seq)} onChange={() => toggle(c.seq)} />
|
||||
<span className="text-content-muted font-mono text-[10px] w-10">{c.seq}</span>
|
||||
<span className={`font-mono text-[11px] font-bold w-14 ${methodClass(c.method)}`}>
|
||||
{c.method}
|
||||
</span>
|
||||
<span className="text-content-primary text-[11px] truncate flex-1">{c.url}</span>
|
||||
<span className="text-content-muted text-[10px] w-8 text-right">{c.status}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 border-t border-border flex justify-end gap-2">
|
||||
<button className="px-3 py-1 rounded text-xs bg-surface-input text-content-primary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 rounded text-xs bg-accent-secondary text-black disabled:opacity-40"
|
||||
disabled={picked.size === 0}
|
||||
onClick={() => onCreate([...picked].sort((a, b) => a - b), name)}
|
||||
>
|
||||
Create chain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isLikelyStep(c: ChainRecordCandidate): boolean {
|
||||
if (c.method !== 'GET') return true
|
||||
const ct = (c.contentType || '').toLowerCase()
|
||||
return ct.includes('json') || ct.includes('html')
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, Flag, KeyRound, Plus, RotateCcw, Wand2, X } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { b64ToBytes, bytesToB64 } from '../../lib/bytes'
|
||||
import { methodClass } from '../../lib/chainStatus'
|
||||
import { stepTitle } from '../../lib/chainMap'
|
||||
import ChainRawPane, { type RawMark } from './ChainRawPane'
|
||||
import ChainBinder from './ChainBinder'
|
||||
import type {
|
||||
ChainBinding, Chain, ChainOnMissing, ChainStep, ChainStepResponse,
|
||||
} from '../../lib/chainTypes'
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-surface-input border border-border rounded px-2 py-1 text-xs text-content-primary'
|
||||
|
||||
type Pane = 'request' | 'response'
|
||||
|
||||
interface Props {
|
||||
chain: Chain
|
||||
step: ChainStep
|
||||
/** The binding the operator clicked on the map, if any. */
|
||||
selectedBindingId?: string | null
|
||||
onChange: (chain: Chain) => void
|
||||
}
|
||||
|
||||
export default function ChainStepInspector({
|
||||
chain, step, selectedBindingId, onChange,
|
||||
}: Props) {
|
||||
const cards = useRef(new Map<string, HTMLDivElement>())
|
||||
const [pane, setPane] = useState<Pane>('request')
|
||||
const [resp, setResp] = useState<ChainStepResponse | null>(null)
|
||||
const [respErr, setRespErr] = useState('')
|
||||
const [binding, setBinding] = useState<string | null>(null)
|
||||
const [hoverBinding, setHoverBinding] = useState<string | null>(null)
|
||||
|
||||
const goalId = chain.goalStepId || chain.steps[chain.steps.length - 1]?.id
|
||||
const consumes = useMemo(
|
||||
() => chain.bindings.filter((b) => b.toStep === step.id),
|
||||
[chain.bindings, step.id],
|
||||
)
|
||||
const produces = useMemo(
|
||||
() => chain.bindings.filter((b) => b.fromStep === step.id),
|
||||
[chain.bindings, step.id],
|
||||
)
|
||||
|
||||
const label = (id: string) => chain.steps.find((s) => s.id === id)?.label ?? id
|
||||
|
||||
// Clicking an arc on the map selects a binding, not a step, so the inspector
|
||||
// has to say which of a step's several bindings was meant. The flash is what
|
||||
// carries that across the pane boundary; scrolling alone is ambiguous when two
|
||||
// bindings are already in view.
|
||||
useEffect(() => {
|
||||
if (!selectedBindingId) return
|
||||
const el = cards.current.get(selectedBindingId)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ block: 'nearest' })
|
||||
el.classList.add('ring-1', 'ring-accent')
|
||||
const t = setTimeout(() => el.classList.remove('ring-1', 'ring-accent'), 900)
|
||||
return () => clearTimeout(t)
|
||||
}, [selectedBindingId, step.id])
|
||||
|
||||
// The recorded response is fetched rather than read off the step: the proxy
|
||||
// leaves Content-Encoding in place, so step.respRaw is usually compressed, and
|
||||
// picking a value out of gzip is not a thing anyone can do.
|
||||
useEffect(() => {
|
||||
setResp(null)
|
||||
setRespErr('')
|
||||
setBinding(null)
|
||||
if (pane !== 'response') return
|
||||
let live = true
|
||||
api
|
||||
.chainStepResponse(chain.id, step.id)
|
||||
.then((r) => live && setResp(r))
|
||||
.catch((e) => live && setRespErr(String((e as Error).message ?? e)))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [pane, chain.id, step.id])
|
||||
|
||||
function patchBinding(id: string, patch: Partial<ChainBinding>) {
|
||||
onChange({
|
||||
...chain,
|
||||
bindings: chain.bindings.map((b) => (b.id === id ? { ...b, ...patch } : b)),
|
||||
})
|
||||
}
|
||||
|
||||
function removeBinding(id: string) {
|
||||
onChange({ ...chain, bindings: chain.bindings.filter((b) => b.id !== id) })
|
||||
}
|
||||
|
||||
const requestMarks: RawMark[] = useMemo(
|
||||
() =>
|
||||
consumes.flatMap((b) =>
|
||||
b.spans.map((sp) => ({
|
||||
start: sp.start,
|
||||
end: sp.end,
|
||||
id: b.id,
|
||||
label: `${b.var}, from ${label(b.fromStep)}`,
|
||||
tone: 'bound' as const,
|
||||
})),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[consumes, chain.steps],
|
||||
)
|
||||
|
||||
// Headers and body in one pane: a regex or between source resolves against
|
||||
// both, so a value in a Set-Cookie has to be selectable too.
|
||||
const responseRaw = useMemo(() => {
|
||||
if (!resp) return ''
|
||||
const h = b64ToBytes(resp.headers)
|
||||
const b = b64ToBytes(resp.body)
|
||||
const all = new Uint8Array(h.length + 2 + b.length)
|
||||
all.set(h, 0)
|
||||
all.set([13, 10], h.length)
|
||||
all.set(b, h.length + 2)
|
||||
return bytesToB64(all)
|
||||
}, [resp])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`font-mono text-xs font-bold ${methodClass(step.method ?? '')}`}>
|
||||
{step.method}
|
||||
</span>
|
||||
<span className="text-content-primary text-sm truncate">{stepTitle(step)}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-content-muted mt-0.5">
|
||||
{step.scheme}://{step.host}
|
||||
{step.originSeq ? ` · seq ${step.originSeq}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{binding && (
|
||||
<ChainBinder
|
||||
chain={chain}
|
||||
fromStep={step.id}
|
||||
valueB64={binding}
|
||||
onCancel={() => setBinding(null)}
|
||||
onAccept={(added) => {
|
||||
setBinding(null)
|
||||
onChange({ ...chain, bindings: [...chain.bindings, ...added] })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto shrink-0 max-h-[55%]">
|
||||
<div className="px-3 py-2 border-b border-border space-y-2">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!step.setup}
|
||||
onChange={() =>
|
||||
onChange({
|
||||
...chain,
|
||||
steps: chain.steps.map((s) => (s.id === step.id ? { ...s, setup: !s.setup } : s)),
|
||||
})
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-content-primary flex items-center gap-1">
|
||||
<RotateCcw size={11} /> Setup step
|
||||
</span>
|
||||
<span className="text-content-muted block mt-0.5">
|
||||
Never skipped, repeated or moved, so it runs at this position in every variant.
|
||||
Mark a login or add-to-cart here, or every variant after the first runs against
|
||||
state the previous one consumed.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={goalId === step.id}
|
||||
onChange={() => onChange({ ...chain, goalStepId: step.id })}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-content-primary flex items-center gap-1">
|
||||
<Flag size={11} /> Goal step
|
||||
</span>
|
||||
<span className="text-content-muted block mt-0.5">
|
||||
This step's outcome decides every verdict - such as whether the checkout went through.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{consumes.length > 0 && (
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<div className="text-[11px] text-content-secondary flex items-center gap-1 mb-1.5">
|
||||
<ArrowDown size={11} /> Needs {consumes.length} value
|
||||
{consumes.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{consumes.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
ref={(el) => {
|
||||
if (el) cards.current.set(b.id, el)
|
||||
else cards.current.delete(b.id)
|
||||
}}
|
||||
className="bg-surface-input rounded px-2 py-1.5"
|
||||
onMouseEnter={() => setHoverBinding(b.id)}
|
||||
onMouseLeave={() => setHoverBinding(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-xs text-accent-secondary flex items-center gap-1">
|
||||
{b.var}
|
||||
<AutoBadge auto={b.auto} />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<button
|
||||
className="text-[10px] text-content-muted hover:text-accent-secondary"
|
||||
onClick={() => setPane('request')}
|
||||
>
|
||||
{b.spans.length} place{b.spans.length === 1 ? '' : 's'}
|
||||
</button>
|
||||
<button
|
||||
className="text-content-muted hover:text-semantic-error"
|
||||
title={`Remove the ${b.var} binding`}
|
||||
aria-label={`Remove the ${b.var} binding`}
|
||||
onClick={() => removeBinding(b.id)}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-content-muted mt-0.5">from {label(b.fromStep)}</div>
|
||||
<label className="flex items-center gap-1.5 mt-1.5">
|
||||
<span className="text-[10px] text-content-secondary">If missing:</span>
|
||||
<select
|
||||
className="bg-surface-card border border-border rounded px-1 py-0.5 text-[10px] text-content-primary"
|
||||
value={b.onMissing ?? 'fail'}
|
||||
onChange={(e) =>
|
||||
patchBinding(b.id, { onMissing: e.target.value as ChainOnMissing })
|
||||
}
|
||||
>
|
||||
<option value="fail">don't send</option>
|
||||
<option value="recorded">send recorded value</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-[10px] text-content-muted mt-1">
|
||||
{b.onMissing === 'recorded'
|
||||
? 'Replays the captured value when the producer is skipped - often the actual test.'
|
||||
: 'A variant that skips the producer reports this step unresolved rather than sending a stale value.'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[11px] text-content-secondary flex items-center gap-1">
|
||||
<KeyRound size={11} /> Produces {produces.length} value
|
||||
{produces.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-0.5 text-[10px] text-content-muted hover:text-accent-secondary"
|
||||
title="Show this step's recorded response and pick a value out of it"
|
||||
onClick={() => setPane('response')}
|
||||
>
|
||||
<Plus size={10} /> Bind value
|
||||
</button>
|
||||
</div>
|
||||
{produces.length === 0 ? (
|
||||
<div className="text-[10px] text-content-muted">
|
||||
Nothing later in the chain takes a value from this response yet. Open the Response
|
||||
pane and select one.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{produces.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
ref={(el) => {
|
||||
if (el) cards.current.set(b.id, el)
|
||||
else cards.current.delete(b.id)
|
||||
}}
|
||||
className="bg-surface-input rounded px-2 py-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<input
|
||||
className="font-mono text-xs text-accent-secondary bg-transparent border-none outline-none min-w-0 flex-1"
|
||||
value={b.var}
|
||||
onChange={(e) => patchBinding(b.id, { var: e.target.value })}
|
||||
/>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
<AutoBadge auto={b.auto} />
|
||||
<span className="text-[10px] text-content-muted">→ {label(b.toStep)}</span>
|
||||
<button
|
||||
className="text-content-muted hover:text-semantic-error"
|
||||
title={`Remove the ${b.var} binding`}
|
||||
aria-label={`Remove the ${b.var} binding`}
|
||||
onClick={() => removeBinding(b.id)}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-content-muted mt-0.5 font-mono truncate">
|
||||
{describeSource(b)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 px-2 pt-1 shrink-0">
|
||||
<PaneTab active={pane === 'request'} onClick={() => setPane('request')}>
|
||||
Request
|
||||
</PaneTab>
|
||||
<PaneTab active={pane === 'response'} onClick={() => setPane('response')}>
|
||||
Response
|
||||
</PaneTab>
|
||||
<div className="flex-1" />
|
||||
{pane === 'response' ? (
|
||||
<span className="text-[10px] text-content-muted">
|
||||
Select a value to bind it into a later step.
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center gap-0.5 text-[10px] text-content-muted hover:text-accent-secondary"
|
||||
title="Show this step's recorded response and pick a value out of it"
|
||||
onClick={() => setPane('response')}
|
||||
>
|
||||
<Plus size={10} /> Bind value
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 px-2 pb-2 pt-1">
|
||||
{pane === 'request' ? (
|
||||
<ChainRawPane
|
||||
className="h-full"
|
||||
raw={step.reqRaw}
|
||||
marks={requestMarks}
|
||||
activeId={hoverBinding ?? selectedBindingId}
|
||||
onMarkClick={(id) => cards.current.get(id)?.scrollIntoView({ block: 'nearest' })}
|
||||
/>
|
||||
) : respErr ? (
|
||||
<div className={`${inputCls} text-semantic-error`}>{respErr}</div>
|
||||
) : !resp ? (
|
||||
<div className="text-[11px] text-content-muted px-1">Loading the recorded response…</div>
|
||||
) : (
|
||||
<ChainRawPane
|
||||
className="h-full"
|
||||
raw={responseRaw}
|
||||
decodedNote={resp.decoded}
|
||||
onSelect={(sel) => setBinding(sel.valueB64)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaneTab({
|
||||
active, onClick, children,
|
||||
}: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2 py-0.5 text-[11px] rounded ${
|
||||
active ? 'bg-surface-input text-accent' : 'text-content-secondary hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Auto marks a binding correlation proposed rather than one the operator wrote.
|
||||
* Worth showing: a week later, "Correlate found this" and "I decided this" lead
|
||||
* to different amounts of trust. */
|
||||
function AutoBadge({ auto }: { auto?: boolean }) {
|
||||
if (!auto) return null
|
||||
return (
|
||||
<span className="text-content-muted" title="Proposed by Correlate">
|
||||
<Wand2 size={10} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function describeSource(b: ChainBinding): string {
|
||||
const s = b.source
|
||||
switch (s.kind) {
|
||||
case 'header':
|
||||
return `header ${s.name}`
|
||||
case 'cookie':
|
||||
return `cookie ${s.name}`
|
||||
case 'json':
|
||||
return `json ${s.path}`
|
||||
case 'regex':
|
||||
return `regex ${s.expr}`
|
||||
case 'between':
|
||||
return `between "${s.prefix}" and "${s.suffix}"`
|
||||
default:
|
||||
return s.kind
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { forwardRef } from 'react'
|
||||
import { ArrowDown, Flag, GripVertical, KeyRound, RotateCcw, X } from 'lucide-react'
|
||||
import { methodClass } from '../../lib/chainStatus'
|
||||
import { requestTarget, stepTitle } from '../../lib/chainMap'
|
||||
import type { ChainBinding, ChainStep } from '../../lib/chainTypes'
|
||||
|
||||
/** How many dependency chips a row shows before collapsing the rest. */
|
||||
const MAX_CHIPS = 3
|
||||
|
||||
export interface StepRowProps {
|
||||
step: ChainStep
|
||||
index: number
|
||||
total: number
|
||||
selected: boolean
|
||||
goal: boolean
|
||||
/** True while this row is the one being dragged. */
|
||||
lifted: boolean
|
||||
/** Rows the pointer is near, and peers of the hovered row, light up. */
|
||||
highlighted: boolean
|
||||
compact: boolean
|
||||
/** Bindings this step consumes and produces, with the peer's row number. */
|
||||
consumes: { binding: ChainBinding; peer: number }[]
|
||||
produces: { binding: ChainBinding; peer: number }[]
|
||||
onSelect: () => void
|
||||
onToggleSetup: () => void
|
||||
onSetGoal: () => void
|
||||
onRemove: () => void
|
||||
onRemoveBinding: (id: string) => void
|
||||
onSelectBinding: (b: ChainBinding) => void
|
||||
onHoverBinding: (id: string | null) => void
|
||||
onGripDown: (e: React.PointerEvent) => void
|
||||
onGripKey: (e: React.KeyboardEvent) => void
|
||||
}
|
||||
|
||||
/** One step in the map.
|
||||
*
|
||||
* A row, not a node: position is the execution order and the gutter arcs are
|
||||
* the data flow, so nothing here should look connectable. */
|
||||
const ChainStepRow = forwardRef<HTMLLIElement, StepRowProps>(function ChainStepRow(
|
||||
{
|
||||
step, index, total, selected, goal, lifted, highlighted, compact,
|
||||
consumes, produces, onSelect, onToggleSetup, onSetGoal, onRemove,
|
||||
onRemoveBinding, onSelectBinding, onHoverBinding, onGripDown, onGripKey,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const border = selected
|
||||
? 'border-accent'
|
||||
: goal
|
||||
? 'border-accent-secondary'
|
||||
: highlighted
|
||||
? 'border-accent-secondary'
|
||||
: 'border-border'
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
onClick={onSelect}
|
||||
className={`relative bg-surface-card border ${border} rounded mb-2 flex items-start gap-2 px-2 ${
|
||||
compact ? 'py-1' : 'py-1.5'
|
||||
} ${lifted ? 'z-10 shadow-lg' : ''}`}
|
||||
>
|
||||
<button
|
||||
className="text-content-muted hover:text-content-primary cursor-grab shrink-0 mt-0.5"
|
||||
title="Drag to reorder, or Alt+Up / Alt+Down"
|
||||
aria-label={`Reorder ${step.label}, position ${index + 1} of ${total}`}
|
||||
onPointerDown={onGripDown}
|
||||
onKeyDown={onGripKey}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={13} />
|
||||
</button>
|
||||
|
||||
<span className="text-content-muted font-mono text-xs w-5 shrink-0 mt-0.5 text-right">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`font-mono text-xs font-bold ${methodClass(step.method ?? '')}`}>
|
||||
{step.method}
|
||||
</span>
|
||||
<span className="text-content-primary text-xs truncate">{stepTitle(step)}</span>
|
||||
</div>
|
||||
|
||||
{!compact && (
|
||||
<div className="text-[10px] text-content-muted break-all line-clamp-2 mt-0.5 font-mono">
|
||||
{requestTarget(step)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && (consumes.length > 0 || produces.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
{consumes.slice(0, MAX_CHIPS).map(({ binding, peer }) => (
|
||||
<Chip
|
||||
key={binding.id}
|
||||
icon={<ArrowDown size={9} />}
|
||||
name={binding.var}
|
||||
peer={`${peer + 1}`}
|
||||
title={`${binding.var} comes from step ${peer + 1}, into ${binding.spans.length} place${
|
||||
binding.spans.length === 1 ? '' : 's'
|
||||
} in this request`}
|
||||
onClick={() => onSelectBinding(binding)}
|
||||
onHover={(on) => onHoverBinding(on ? binding.id : null)}
|
||||
onRemove={() => onRemoveBinding(binding.id)}
|
||||
/>
|
||||
))}
|
||||
{produces.slice(0, MAX_CHIPS).map(({ binding, peer }) => (
|
||||
<Chip
|
||||
key={binding.id}
|
||||
icon={<KeyRound size={9} />}
|
||||
name={binding.var}
|
||||
peer={`→${peer + 1}`}
|
||||
title={`${binding.var} is read from this response and sent by step ${peer + 1}`}
|
||||
onClick={() => onSelectBinding(binding)}
|
||||
onHover={(on) => onHoverBinding(on ? binding.id : null)}
|
||||
onRemove={() => onRemoveBinding(binding.id)}
|
||||
/>
|
||||
))}
|
||||
<Overflow consumes={consumes} produces={produces} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0 mt-0.5">
|
||||
{step.setup && (
|
||||
<button
|
||||
className="text-semantic-info"
|
||||
title="Setup: never skipped, repeated or moved, so it re-runs before every variant"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleSetup()
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
)}
|
||||
{goal && (
|
||||
<span className="text-accent-secondary" title="Goal: this step's outcome decides each verdict">
|
||||
<Flag size={13} />
|
||||
</span>
|
||||
)}
|
||||
{!goal && !compact && (
|
||||
<button
|
||||
className="text-content-muted hover:text-accent-secondary"
|
||||
title="Make this the goal step"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSetGoal()
|
||||
}}
|
||||
>
|
||||
<Flag size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="text-content-muted hover:text-semantic-error"
|
||||
title="Remove this step"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
function Overflow({
|
||||
consumes, produces,
|
||||
}: { consumes: StepRowProps['consumes']; produces: StepRowProps['produces'] }) {
|
||||
const hidden =
|
||||
Math.max(0, consumes.length - MAX_CHIPS) + Math.max(0, produces.length - MAX_CHIPS)
|
||||
if (hidden === 0) return null
|
||||
const names = [...consumes.slice(MAX_CHIPS), ...produces.slice(MAX_CHIPS)]
|
||||
.map((c) => c.binding.var)
|
||||
.join(', ')
|
||||
return (
|
||||
<span className="text-[10px] text-content-muted" title={names}>
|
||||
+{hidden}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** One dependency, named. "How many" is not a question anyone has; "which value,
|
||||
* and from where" is, and the arcs only answer it for ones the eye can trace. */
|
||||
function Chip({
|
||||
icon, name, peer, title, onClick, onHover, onRemove,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
name: string
|
||||
peer: string
|
||||
title: string
|
||||
onClick: () => void
|
||||
onHover: (on: boolean) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="group inline-flex items-center gap-0.5 pl-1 pr-0.5 py-px rounded bg-surface-input border border-border-subtle text-[10px] text-content-secondary"
|
||||
title={title}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
>
|
||||
<button
|
||||
className="inline-flex items-center gap-0.5 hover:text-accent-secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span className="font-mono">{name}</span>
|
||||
<span className="text-content-muted">{peer}</span>
|
||||
</button>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 text-content-muted hover:text-semantic-error"
|
||||
title={`Remove the ${name} binding`}
|
||||
aria-label={`Remove the ${name} binding`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChainStepRow
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Eye, Play, Square, Trash2 } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { b64DecodeUTF8 } from '../../lib/bytes'
|
||||
import { useResizable } from '../../lib/useResizable'
|
||||
import { useChainStore } from '../../stores/chainStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import type { Chain, ChainPlan, ChainPreviewStep } from '../../lib/chainTypes'
|
||||
import ChainGrid from './ChainGrid'
|
||||
import ChainCellDetail from './ChainCellDetail'
|
||||
|
||||
const KINDS = [
|
||||
{ id: 'omit', label: 'Skip each step', hint: 'Does the application enforce that the step happened?' },
|
||||
{ id: 'repeat', label: 'Repeat each step', hint: 'Is the step idempotent, or does it stack?' },
|
||||
{ id: 'move', label: 'Swap neighbours', hint: 'Is the order enforced, or only suggested?' },
|
||||
]
|
||||
|
||||
export default function ChainSweepView({ chain }: { chain: Chain }) {
|
||||
const store = useChainStore()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const split = useResizable('horizontal', 0.6)
|
||||
|
||||
const [kinds, setKinds] = useState<string[]>(['omit'])
|
||||
const [armed, setArmed] = useState(false)
|
||||
const [delayMs, setDelayMs] = useState(250)
|
||||
const [plan, setPlan] = useState<ChainPlan | null>(null)
|
||||
const [preview, setPreview] = useState<ChainPreviewStep[] | null>(null)
|
||||
|
||||
// activeRunId survives a chain switch, so without this check the grid renders
|
||||
// one chain's verdicts under another's name, and Stop stops the wrong sweep.
|
||||
const openRun = store.activeRunId ? store.runs[store.activeRunId] : null
|
||||
const run = openRun && openRun.chainId === chain.id ? openRun : null
|
||||
const running = run?.status === 'running'
|
||||
|
||||
// The plan is fetched whenever the selection changes so the request count and
|
||||
// the warnings sit beside the arming checkbox, rather than arriving after the
|
||||
// operator has already committed to sending them.
|
||||
useEffect(() => {
|
||||
if (kinds.length === 0) {
|
||||
setPlan(null)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
api
|
||||
.chainPlan(chain.id, kinds)
|
||||
.then((p) => live && setPlan(p))
|
||||
.catch(() => live && setPlan(null))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [chain.id, kinds, chain.updatedAt])
|
||||
|
||||
const toggleKind = (id: string) =>
|
||||
setKinds((k) => (k.includes(id) ? k.filter((x) => x !== id) : [...k, id]))
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.chainStartRun({
|
||||
chainId: chain.id,
|
||||
kinds,
|
||||
allowStateChanging: armed,
|
||||
delayMs,
|
||||
})
|
||||
store.startRun(res.runId, chain.id, chain.name, res.total, res.warnings ?? [])
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}, [chain, kinds, armed, delayMs, store, addToast])
|
||||
|
||||
const doPreview = useCallback(async () => {
|
||||
try {
|
||||
const first = plan?.variants.find((v) => v.kind !== 'baseline') ?? plan?.variants[0]
|
||||
if (!first) return
|
||||
const res = await api.chainPreview(chain.id, first.id, kinds)
|
||||
setPreview(res.steps)
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}, [chain.id, kinds, plan, addToast])
|
||||
|
||||
const needsArming = (plan?.methods.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="px-3 py-2 border-b border-border bg-surface-card shrink-0">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{KINDS.map((k) => (
|
||||
<label key={k.id} className="flex items-center gap-1.5 cursor-pointer" title={k.hint}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={kinds.includes(k.id)}
|
||||
onChange={() => toggleKind(k.id)}
|
||||
disabled={running}
|
||||
/>
|
||||
<span className="text-xs text-content-primary">{k.label}</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
Delay
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={60000}
|
||||
value={delayMs}
|
||||
onChange={(e) => setDelayMs(Number(e.target.value))}
|
||||
disabled={running}
|
||||
className="w-16 bg-surface-input border border-border rounded px-1.5 py-0.5 text-xs text-content-primary"
|
||||
/>
|
||||
ms
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={doPreview}
|
||||
disabled={running || !plan}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-surface-input text-content-primary disabled:opacity-40"
|
||||
title="Render one variant's requests without sending anything"
|
||||
>
|
||||
<Eye size={12} /> Preview
|
||||
</button>
|
||||
|
||||
{running ? (
|
||||
<button
|
||||
onClick={() => run && api.chainStopRun(run.id).catch(() => {})}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-semantic-error-bg text-content-primary"
|
||||
>
|
||||
<Square size={12} /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={kinds.length === 0 || (needsArming && !armed)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent-tertiary text-black disabled:opacity-40"
|
||||
>
|
||||
<Play size={12} /> Run sweep
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsArming && (
|
||||
<label className="flex items-start gap-2 mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={armed}
|
||||
onChange={() => setArmed(!armed)}
|
||||
disabled={running}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs text-content-primary flex items-center gap-1">
|
||||
<AlertTriangle size={12} className="text-semantic-warning" />
|
||||
Allow state-changing requests ({plan?.methods.join(', ')})
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{plan?.warnings.map((warn) => (
|
||||
<div key={warn} className="text-[11px] text-semantic-warning mt-1.5 flex items-start gap-1">
|
||||
<AlertTriangle size={11} className="mt-0.5 shrink-0" />
|
||||
<span>{warn}</span>
|
||||
</div>
|
||||
))}
|
||||
{plan?.error && <div className="text-[11px] text-semantic-error mt-1.5">{plan.error}</div>}
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="px-3 py-2 border-b border-border bg-surface-input shrink-0 max-h-56 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-content-secondary">
|
||||
Preview - nothing was sent
|
||||
</span>
|
||||
<button className="text-content-muted hover:text-content-primary" onClick={() => setPreview(null)}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{preview.map((p) => (
|
||||
<div key={p.stepId + p.label} className="mb-2">
|
||||
<div className="text-[11px] text-content-primary">{p.label}</div>
|
||||
{p.error && <div className="text-[11px] text-semantic-error">{p.error}</div>}
|
||||
{p.missing && (
|
||||
<div className="text-[11px] text-semantic-warning">
|
||||
unresolved: {p.missing.map((m) => m.var).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{p.raw && (
|
||||
<pre className="font-mono text-[10px] text-content-muted whitespace-pre-wrap break-all mt-0.5">
|
||||
{b64DecodeUTF8(p.raw)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run && (
|
||||
<div className="px-3 py-1.5 border-b border-border flex items-center gap-3 text-xs shrink-0">
|
||||
<div className="w-40 h-1.5 bg-surface-input rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-secondary transition-all"
|
||||
style={{ width: `${run.total ? (run.completed / run.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-content-secondary">
|
||||
{run.completed}/{run.total}
|
||||
</span>
|
||||
<span className="text-content-muted">{run.status}</span>
|
||||
{run.bypassed > 0 && (
|
||||
<span className="text-semantic-error">{run.bypassed} to review</span>
|
||||
)}
|
||||
{run.unresolved > 0 && (
|
||||
<span className="text-semantic-warning">{run.unresolved} unresolved</span>
|
||||
)}
|
||||
{run.errors > 0 && <span className="text-semantic-error">{run.errors} errors</span>}
|
||||
<div className="flex-1" />
|
||||
{!running && (
|
||||
<button
|
||||
className="text-content-muted hover:text-content-primary"
|
||||
title="Clear this run"
|
||||
onClick={() => {
|
||||
api.chainDeleteRun(run.id).catch(() => {})
|
||||
store.clearRun(run.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run ? (
|
||||
<div ref={split.containerRef} className="flex flex-1 min-h-0">
|
||||
<div style={{ width: `${split.fraction * 100}%` }} className="flex flex-col min-h-0">
|
||||
<ChainGrid
|
||||
run={run}
|
||||
selectedIndex={run.selectedIndex}
|
||||
onSelect={(i) => store.updateRun(run.id, { selectedIndex: i })}
|
||||
/>
|
||||
</div>
|
||||
<div className="drag-handle-h" {...split.handleProps} />
|
||||
<div className="flex-1 min-w-0 flex flex-col border-l border-border">
|
||||
<ChainCellDetail runId={run.id} index={run.selectedIndex} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-content-muted text-sm px-8 text-center">
|
||||
<div>
|
||||
<div>Pick which mutations to try, then run the sweep.</div>
|
||||
<div className="text-xs mt-2 max-w-md">
|
||||
Every run measures the chain unchanged first and compares each variant against
|
||||
that, so a fresh session token is not mistaken for the application behaving
|
||||
differently.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export default function ActiveUsersWidget() {
|
||||
</div>
|
||||
{!announcing && (
|
||||
<p className="text-[10px] text-content-muted leading-snug">
|
||||
Hidden from teammates — add the Team Chat widget to your dashboard to appear online.
|
||||
Hidden from teammates - add the Team Chat widget to your dashboard to appear online.
|
||||
</p>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-terminal cursor-pointer">
|
||||
|
||||
@@ -17,12 +17,12 @@ interface LocalChatMessage {
|
||||
|
||||
const SLASH_HELP = [
|
||||
'Available slash commands:',
|
||||
'/me <text> — send an action message',
|
||||
'/slap <user> — slap someone with a large trout',
|
||||
'/nick <name> — change your nickname',
|
||||
'/flag <seq> [note] — flag a captured request (seq from History)',
|
||||
'/collab <note> — request collaboration (share scope / M&R / custom data)',
|
||||
'/help — show this help',
|
||||
'/me <text> - send an action message',
|
||||
'/slap <user> - slap someone with a large trout',
|
||||
'/nick <name> - change your nickname',
|
||||
'/flag <seq> [note] - flag a captured request (seq from History)',
|
||||
'/collab <note> - request collaboration (share scope / M&R / custom data)',
|
||||
'/help - show this help',
|
||||
].join('\n')
|
||||
|
||||
export default function TeamChatWidget() {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Play, Square, AlertTriangle } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useSJStore, resolveScope, type SJTab } from '../../stores/sjStore'
|
||||
import SJScopePicker, { scopeLabel } from './SJScopePicker'
|
||||
import { rawToCurl } from '../../lib/httpTransform'
|
||||
import { b64DecodeUTF8 } from '../../lib/bytes'
|
||||
import type { SJResult, SJTriage } from '../../lib/sjTypes'
|
||||
import { triagePill, methodClass, formatSize } from '../../lib/sjStatus'
|
||||
import { useResizable } from '../../lib/useResizable'
|
||||
import ContextMenu, { type MenuItem } from '../ContextMenu'
|
||||
import { getSelectionMenuItems } from '../../lib/selectionMenu'
|
||||
import { copyText } from '../../lib/clipboard'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import SJResultDetail from './SJResultDetail'
|
||||
|
||||
export default function SJAutomateView({ tab }: { tab: SJTab }) {
|
||||
const store = useSJStore()
|
||||
const navigate = useNavigate()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const split = useResizable('horizontal', 0.55)
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
||||
const run = tab.scanRun
|
||||
const spec = tab.spec!
|
||||
const ops = spec.operations
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const res = await api.sjScan({
|
||||
specId: spec.id,
|
||||
operationIds: resolveScope(tab.scope, ops),
|
||||
profileIds: tab.activeProfileId ? [tab.activeProfileId] : undefined,
|
||||
serverIndex: tab.serverIndex,
|
||||
scheme: tab.scheme,
|
||||
host: tab.host,
|
||||
basePath: tab.basePath,
|
||||
concurrency: tab.concurrency,
|
||||
ratePerSec: tab.ratePerSec,
|
||||
userAgent: tab.userAgent || undefined,
|
||||
allowDestructive: tab.allowDestructive,
|
||||
})
|
||||
store.startRun(tab.id, 'scanRun', res.runId, res.kind, res.total, res.warnings ?? [])
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function rowMenu(r: SJResult): MenuItem[] {
|
||||
return [
|
||||
...getSelectionMenuItems(navigate),
|
||||
{
|
||||
label: 'Open in form',
|
||||
disabled: !r.opId,
|
||||
onClick: () => store.updateTab(tab.id, { subTab: 'operations', selectedOpId: r.opId ?? null }),
|
||||
},
|
||||
{
|
||||
label: 'View in History',
|
||||
disabled: !r.requestId,
|
||||
onClick: () => navigate('/history', { state: { focusRequestId: r.requestId } }),
|
||||
},
|
||||
{
|
||||
// The bytes are fetched on demand rather than held in the row: a scan
|
||||
// keeps thousands of results and only one is ever copied.
|
||||
label: 'Copy as curl',
|
||||
disabled: !r.url || !!r.skipped,
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
try {
|
||||
const d = await api.sjGetResult(run!.id, r.index)
|
||||
await copyText(rawToCurl(b64DecodeUTF8(d.reqRaw), r.url ?? ''))
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
})(),
|
||||
},
|
||||
{ label: 'Copy URL', disabled: !r.url, onClick: () => void copyText(r.url ?? '') },
|
||||
]
|
||||
}
|
||||
|
||||
const results = (run?.results ?? []).filter(Boolean)
|
||||
const counts = results.reduce<Record<SJTriage, number>>(
|
||||
(acc, r) => {
|
||||
if (!r.skipped) acc[r.triage] = (acc[r.triage] ?? 0) + 1
|
||||
return acc
|
||||
},
|
||||
{ good: 0, warn: 0, bad: 0 }
|
||||
)
|
||||
const shown = run?.triageFilter ? results.filter((r) => r.triage === run.triageFilter && !r.skipped) : results
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border shrink-0 flex-wrap text-[11px]">
|
||||
<SJScopePicker
|
||||
scope={tab.scope}
|
||||
ops={ops}
|
||||
disabled={run?.status === 'running'}
|
||||
onChange={(next) => store.updateTab(tab.id, { scope: next })}
|
||||
/>
|
||||
<label className="flex items-center gap-1">
|
||||
threads
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={tab.concurrency}
|
||||
onChange={(e) => store.updateTab(tab.id, { concurrency: Number(e.target.value) })}
|
||||
className="w-12 bg-surface-input border border-border rounded-sm px-1 py-0.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
rate/s
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={tab.ratePerSec}
|
||||
onChange={(e) => store.updateTab(tab.id, { ratePerSec: Number(e.target.value) })}
|
||||
className="w-12 bg-surface-input border border-border rounded-sm px-1 py-0.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1" title="DELETE and PATCH, and operations whose name suggests they change something, are skipped unless this is ticked">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tab.allowDestructive}
|
||||
onChange={(e) => store.updateTab(tab.id, { allowDestructive: e.target.checked })}
|
||||
/>
|
||||
allow destructive
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{run?.status === 'running' ? (
|
||||
<button
|
||||
onClick={() => void api.sjStopRun(run.id).catch(() => {})}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-sm bg-semantic-error-bg text-content-primary text-[11px]"
|
||||
>
|
||||
<Square size={10} /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void start()}
|
||||
disabled={!tab.host}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-sm bg-accent-tertiary text-black text-[11px] font-semibold disabled:opacity-50"
|
||||
>
|
||||
<Play size={10} /> Start scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run && (
|
||||
<>
|
||||
{run.warnings.length > 0 && (
|
||||
<div className="px-2 py-1 border-b border-border shrink-0">
|
||||
{run.warnings.map((wmsg, i) => (
|
||||
<div key={i} className="flex items-center gap-1 text-[10px] text-semantic-warning">
|
||||
<AlertTriangle size={10} /> {wmsg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-border shrink-0 text-[10px]">
|
||||
<div className="h-1.5 flex-1 bg-surface-input rounded-sm overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-secondary"
|
||||
style={{ width: `${run.total ? (run.completed / run.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-content-muted tabular-nums">
|
||||
{run.completed}/{run.total}
|
||||
</span>
|
||||
<span className="text-content-secondary">{run.status}</span>
|
||||
<span className="text-content-muted">· {scopeLabel(tab.scope, ops)}</span>
|
||||
{run.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => store.clearRun(tab.id, 'scanRun')}
|
||||
className="text-content-muted hover:text-content-primary"
|
||||
title="Dismiss these results"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{(['good', 'warn', 'bad'] as SJTriage[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => store.updateRun(run.id, { triageFilter: run.triageFilter === t ? null : t })}
|
||||
className={`px-1 rounded-sm ${run.triageFilter === t ? 'bg-surface-hover' : ''}`}
|
||||
>
|
||||
{triagePill(t, 0)}
|
||||
<span className="ml-0.5 text-content-muted">{counts[t]}</span>
|
||||
</button>
|
||||
))}
|
||||
{run.skipped > 0 && <span className="text-content-muted">{run.skipped} skipped</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div ref={split.containerRef} className="flex flex-1 min-h-0">
|
||||
<div style={{ flex: run?.selectedIndex != null ? split.fraction : 1 }} className="min-w-0 overflow-auto">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="sticky top-0 bg-surface-card z-10">
|
||||
<tr className="text-content-muted text-left">
|
||||
<th className="px-2 py-1 font-normal w-16">Method</th>
|
||||
<th className="px-2 py-1 font-normal">Path</th>
|
||||
<th className="px-2 py-1 font-normal w-20">Status</th>
|
||||
<th className="px-2 py-1 font-normal w-16 text-right">Size</th>
|
||||
<th className="px-2 py-1 font-normal w-14 text-right">ms</th>
|
||||
<th className="px-2 py-1 font-normal w-40">Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((r) => (
|
||||
<tr
|
||||
key={r.index}
|
||||
onClick={() => run && store.updateRun(run.id, { selectedIndex: r.index })}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setMenu({ x: e.clientX, y: e.clientY, items: rowMenu(r) })
|
||||
}}
|
||||
className={`border-b border-border-subtle cursor-pointer hover:bg-surface-hover ${
|
||||
run?.selectedIndex === r.index ? 'bg-surface-hover' : ''
|
||||
}`}
|
||||
>
|
||||
<td className={`px-2 py-0.5 font-bold text-[10px] ${methodClass(r.method ?? '')}`}>{r.method}</td>
|
||||
{/* The path as rendered, so the generated values are readable
|
||||
without opening the row. A row skipped before it rendered
|
||||
has none, and falls back to the document's template. */}
|
||||
<td className="px-2 py-0.5 font-mono truncate max-w-0" title={r.url || r.path}>
|
||||
{r.reqPath || r.path}
|
||||
</td>
|
||||
<td className="px-2 py-0.5">{triagePill(r.triage, r.status, r.skipped)}</td>
|
||||
<td className="px-2 py-0.5 text-right text-content-muted tabular-nums">{r.skipped ? '' : formatSize(r.len)}</td>
|
||||
<td className="px-2 py-0.5 text-right text-content-muted tabular-nums">{r.skipped ? '' : r.ms}</td>
|
||||
<td className="px-2 py-0.5 text-content-muted truncate max-w-0" title={r.note || r.error || ''}>
|
||||
{r.error || r.note}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!run && (
|
||||
<div className="p-3 text-[11px] text-content-muted">
|
||||
No scan yet. Every request goes through Joro's proxy, so results also land in History,
|
||||
the Site Map and Detect.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run?.selectedIndex != null && (
|
||||
<>
|
||||
<div className="drag-handle-h" {...split.handleProps} />
|
||||
<div style={{ flex: 1 - split.fraction }} className="min-w-0">
|
||||
<SJResultDetail runId={run.id} index={run.selectedIndex} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Play, Square, FileJson, AlertTriangle, Info } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useSJStore, type SJTab } from '../../stores/sjStore'
|
||||
import type { SJResult } from '../../lib/sjTypes'
|
||||
import { formatSize } from '../../lib/sjStatus'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { copyText } from '../../lib/clipboard'
|
||||
import { rawToCurl } from '../../lib/httpTransform'
|
||||
import { b64DecodeUTF8 } from '../../lib/bytes'
|
||||
import ContextMenu, { type MenuItem } from '../ContextMenu'
|
||||
import { getSelectionMenuItems } from '../../lib/selectionMenu'
|
||||
|
||||
export default function SJBruteView({
|
||||
tab,
|
||||
onFailure,
|
||||
}: {
|
||||
tab: SJTab
|
||||
onFailure: (tabId: string, e: unknown) => void
|
||||
}) {
|
||||
const store = useSJStore()
|
||||
const navigate = useNavigate()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const [loadingSpec, setLoadingSpec] = useState('')
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
||||
const [browser, setBrowser] = useState<{ available: boolean; browser: string } | null>(null)
|
||||
const run = tab.discoverRun
|
||||
const d = tab.discover
|
||||
|
||||
// Asked once: the menu needs to say whether the managed browser exists rather
|
||||
// than offering an action that fails, the way TestingBrowserButton does.
|
||||
useEffect(() => {
|
||||
api.browserStatus().then(setBrowser).catch(() => {})
|
||||
}, [])
|
||||
|
||||
function setDiscover(patch: Partial<SJTab['discover']>) {
|
||||
store.updateTab(tab.id, { discover: { ...d, ...patch } })
|
||||
}
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const res = await api.sjDiscover({
|
||||
scheme: d.scheme,
|
||||
host: d.host.trim(),
|
||||
basePath: d.basePath,
|
||||
full: d.full,
|
||||
stopOnFirst: d.stopOnFirst,
|
||||
concurrency: tab.bruteThreads,
|
||||
ratePerSec: tab.ratePerSec,
|
||||
userAgent: tab.userAgent || undefined,
|
||||
})
|
||||
store.startRun(tab.id, 'discoverRun', res.runId, res.kind, res.total, res.warnings ?? [])
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFound(url: string) {
|
||||
setLoadingSpec(url)
|
||||
// Into the current slot when it is empty, otherwise a new one — the same
|
||||
// rule the header loader uses, so finding a second document never replaces
|
||||
// the one already open.
|
||||
const id = tab.spec ? store.addTab() : tab.id
|
||||
// A document the sweep already parsed is in the spec store, so open it by
|
||||
// id: re-fetching would put a second request on a live target for bytes
|
||||
// Joro is holding. The fetch stays as the fallback, for a row the sweep
|
||||
// never classified as a document and the operator is trying by hand.
|
||||
const already = run?.found.find((f) => f.sourceUrl === url)
|
||||
try {
|
||||
const res = already ? await api.sjGetSpec(already.specId) : await api.sjLoad({ url })
|
||||
store.setSpec(id, res.spec)
|
||||
} catch (e) {
|
||||
onFailure(id, e)
|
||||
} finally {
|
||||
setLoadingSpec('')
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a discovered URL in the managed testing browser, which routes through
|
||||
* Joro's proxy and trusts its CA — so a Swagger UI page found by the sweep is
|
||||
* explorable with its traffic captured, rather than only readable as bytes. */
|
||||
async function openInBrowser(url: string) {
|
||||
try {
|
||||
const res = await api.launchBrowser({ url })
|
||||
addToast(`Opened in ${res.browser}`, 'info')
|
||||
} catch (e) {
|
||||
addToast(`Launch failed: ${(e as Error).message ?? e}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function rowMenu(r: SJResult): MenuItem[] {
|
||||
const isDoc = r.bodyKind === 'spec'
|
||||
const browserLabel = browser && !browser.available
|
||||
? 'Open in testing browser (none detected)'
|
||||
: 'Open in testing browser'
|
||||
return [
|
||||
...getSelectionMenuItems(navigate),
|
||||
{
|
||||
label: isDoc ? 'Load as document' : 'Try loading as a document',
|
||||
disabled: !r.url,
|
||||
onClick: () => void loadFound(r.url ?? ''),
|
||||
},
|
||||
{
|
||||
label: browserLabel,
|
||||
disabled: !r.url || (browser !== null && !browser.available),
|
||||
onClick: () => void openInBrowser(r.url ?? ''),
|
||||
},
|
||||
{
|
||||
label: 'View in History',
|
||||
disabled: !r.requestId,
|
||||
onClick: () => navigate('/history', { state: { focusRequestId: r.requestId } }),
|
||||
},
|
||||
{
|
||||
label: 'Copy as curl',
|
||||
disabled: !r.url,
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await api.sjGetResult(run!.id, r.index)
|
||||
await copyText(rawToCurl(b64DecodeUTF8(detail.reqRaw), r.url ?? ''))
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
})(),
|
||||
},
|
||||
{ label: 'Copy URL', disabled: !r.url, onClick: () => void copyText(r.url ?? '') },
|
||||
]
|
||||
}
|
||||
|
||||
const results = (run?.results ?? []).filter(Boolean)
|
||||
const interesting = results.filter((r) => r.bodyKind === 'spec' || r.bodyKind === 'weak' || r.bodyKind === 'reference')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{tab.htmlHint && (
|
||||
<div className="flex items-start gap-1.5 px-2 py-1.5 border-b border-border shrink-0 text-[11px]">
|
||||
<Info size={12} className="text-semantic-info shrink-0 mt-px" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-content-secondary">{tab.htmlHint.message}</div>
|
||||
{tab.htmlHint.candidates.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap mt-1">
|
||||
{tab.htmlHint.candidates.slice(0, 12).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => void loadFound(absoluteCandidate(c, tab))}
|
||||
className="px-1.5 py-0.5 rounded-sm bg-surface-input hover:bg-surface-hover text-[10px] font-mono text-content-secondary"
|
||||
title="Load this one"
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border shrink-0 flex-wrap text-[11px]">
|
||||
<select
|
||||
value={d.scheme}
|
||||
onChange={(e) => setDiscover({ scheme: e.target.value })}
|
||||
className="bg-surface-input border border-border rounded-sm px-1 py-0.5"
|
||||
>
|
||||
<option value="https">https</option>
|
||||
<option value="http">http</option>
|
||||
</select>
|
||||
<input
|
||||
value={d.host}
|
||||
onChange={(e) => setDiscover({ host: e.target.value })}
|
||||
onKeyDown={(e) => e.key === 'Enter' && d.host.trim() && void start()}
|
||||
placeholder="target host"
|
||||
className="bg-surface-input border border-border rounded-sm px-1.5 py-0.5 w-56"
|
||||
/>
|
||||
<input
|
||||
value={d.basePath}
|
||||
onChange={(e) => setDiscover({ basePath: e.target.value })}
|
||||
placeholder="/base"
|
||||
className="bg-surface-input border border-border rounded-sm px-1.5 py-0.5 w-24"
|
||||
/>
|
||||
<label
|
||||
className="flex items-center gap-1"
|
||||
title="The full sweep crosses every prefix with every name - thousands of requests, all of them captured into History, which can evict traffic already there."
|
||||
>
|
||||
<input type="checkbox" checked={d.full} onChange={(e) => setDiscover({ full: e.target.checked })} />
|
||||
full sweep
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={d.stopOnFirst} onChange={(e) => setDiscover({ stopOnFirst: e.target.checked })} />
|
||||
stop at first
|
||||
</label>
|
||||
<div className="ml-auto">
|
||||
{run?.status === 'running' ? (
|
||||
<button
|
||||
onClick={() => void api.sjStopRun(run.id).catch(() => {})}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-sm bg-semantic-error-bg text-content-primary text-[11px]"
|
||||
>
|
||||
<Square size={10} /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void start()}
|
||||
disabled={!d.host.trim()}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-sm bg-accent-tertiary text-black text-[11px] font-semibold disabled:opacity-50"
|
||||
>
|
||||
<Play size={10} /> Discover
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.full && !run && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-[10px] text-semantic-warning border-b border-border shrink-0">
|
||||
<AlertTriangle size={10} />
|
||||
A full sweep is roughly 2,200 requests. Every one is captured in History and may evict
|
||||
traffic already there.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run && (
|
||||
<>
|
||||
{run.warnings.length > 0 && (
|
||||
<div className="px-2 py-1 border-b border-border shrink-0">
|
||||
{run.warnings.map((wmsg, i) => (
|
||||
<div key={i} className="flex items-center gap-1 text-[10px] text-semantic-warning">
|
||||
<AlertTriangle size={10} /> {wmsg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-border shrink-0 text-[10px]">
|
||||
<div className="h-1.5 flex-1 bg-surface-input rounded-sm overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-secondary"
|
||||
style={{ width: `${run.total ? (run.completed / run.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-content-muted tabular-nums">
|
||||
{run.completed}/{run.total}
|
||||
</span>
|
||||
<span className="text-content-secondary">{run.status}</span>
|
||||
<span className="text-semantic-success">{run.hits} found</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{interesting.length === 0 ? (
|
||||
<div className="p-3 text-[11px] text-content-muted">
|
||||
{run
|
||||
? run.status === 'running'
|
||||
? 'Sweeping…'
|
||||
: 'Nothing that looked like a document. Try the full sweep, or a base path.'
|
||||
: 'Give a host and run Discover. Every candidate goes through Joro’s proxy, so hits land in History too.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="sticky top-0 bg-surface-card z-10">
|
||||
<tr className="text-content-muted text-left">
|
||||
<th className="px-2 py-1 font-normal">Path</th>
|
||||
<th className="px-2 py-1 font-normal w-16">Status</th>
|
||||
<th className="px-2 py-1 font-normal w-16 text-right">Size</th>
|
||||
<th className="px-2 py-1 font-normal w-24">Kind</th>
|
||||
<th className="px-2 py-1 font-normal">Document</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{interesting.map((r) => (
|
||||
<tr
|
||||
key={r.index}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setMenu({ x: e.clientX, y: e.clientY, items: rowMenu(r) })
|
||||
}}
|
||||
className="border-b border-border-subtle hover:bg-surface-hover cursor-context-menu"
|
||||
>
|
||||
<td className="px-2 py-0.5 font-mono truncate max-w-0" title={r.url}>
|
||||
{r.bodyKind === 'spec' && <FileJson size={11} className="inline mr-1 text-accent-secondary" />}
|
||||
{r.path}
|
||||
</td>
|
||||
<td className="px-2 py-0.5 text-content-secondary tabular-nums">{r.status}</td>
|
||||
<td className="px-2 py-0.5 text-right text-content-muted tabular-nums">{formatSize(r.len)}</td>
|
||||
<td className="px-2 py-0.5 text-content-muted">{r.bodyKind}</td>
|
||||
<td className="px-2 py-0.5">
|
||||
{r.bodyKind === 'spec' ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-content-secondary truncate">
|
||||
{r.specTitle} · {r.specOps} ops
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void loadFound(r.url ?? '')}
|
||||
disabled={loadingSpec === r.url}
|
||||
className="text-accent-secondary hover:underline shrink-0"
|
||||
>
|
||||
{loadingSpec === r.url ? 'loading…' : 'Load'}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-content-muted">{r.contentType}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** absoluteCandidate turns a path the Swagger UI page referenced into a URL on
|
||||
* the target this tab is pointed at. An already-absolute one is left alone. */
|
||||
function absoluteCandidate(candidate: string, tab: SJTab): string {
|
||||
if (candidate.includes('://')) return candidate
|
||||
const base = `${tab.discover.scheme || 'https'}://${tab.discover.host}`
|
||||
return candidate.startsWith('/') ? base + candidate : `${base}/${candidate}`
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { FileJson, ChevronDown, Plus, X, AlertTriangle, Circle, SlidersHorizontal } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useSJStore, tabLosses, anyLoss, type SJTab, type SJLosses } from '../../stores/sjStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import ConfirmModal from '../ConfirmModal'
|
||||
import type { SJSpec, SJPlaceholders } from '../../lib/sjTypes'
|
||||
import type { DocTab } from './SJDocumentModal'
|
||||
|
||||
/** The document bar: what you are working on, where it points, and who you are.
|
||||
*
|
||||
* These three were previously spread across a sub-tab, a toolbar that vanished
|
||||
* on Discover, and a panel buried inside the Auth Matrix. They are all
|
||||
* properties of the document, so they belong together and above the work. */
|
||||
export default function SJDocumentBar({
|
||||
tab,
|
||||
onOpenDoc,
|
||||
onLoadNew,
|
||||
onOpenProfiles,
|
||||
}: {
|
||||
tab: SJTab
|
||||
onOpenDoc: (docTab: DocTab) => void
|
||||
/** Opens the loader against a fresh document slot, so loading a second
|
||||
* document does not silently replace the one in front of you. */
|
||||
onLoadNew: () => void
|
||||
onOpenProfiles: () => void
|
||||
}) {
|
||||
const store = useSJStore()
|
||||
const { tabs } = store
|
||||
const spec = tab.spec
|
||||
const diagnostics = spec?.diagnostics?.length ?? 0
|
||||
const [confirming, setConfirming] = useState<{ id: string; name: string; losses: SJLosses } | null>(null)
|
||||
|
||||
/** unloadDocument removes a document everywhere, not just from the list.
|
||||
*
|
||||
* Order matters: stop the runs, delete them, then the document, then the tab.
|
||||
* The server keeps a parsed document, its source bytes and its auth profile
|
||||
* credentials until told otherwise, and nothing used to tell it. */
|
||||
async function unloadDocument(id: string) {
|
||||
const target = useSJStore.getState().tabs.find((t) => t.id === id)
|
||||
if (!target) return
|
||||
|
||||
const runs = [target.scanRun, target.matrixRun, target.discoverRun].filter(Boolean)
|
||||
for (const r of runs) {
|
||||
if (r!.status === 'running') await api.sjStopRun(r!.id).catch(() => {})
|
||||
await api.sjDeleteRun(r!.id).catch(() => {})
|
||||
}
|
||||
|
||||
// Only when nothing else holds it. A spec id is a hash of the document's
|
||||
// bytes, so the same document loaded twice is one server-side entry behind
|
||||
// two tabs — deleting unconditionally would 404 every render, send and scan
|
||||
// the survivor makes.
|
||||
const specID = target.spec?.id
|
||||
if (specID) {
|
||||
const stillReferenced = useSJStore
|
||||
.getState()
|
||||
.tabs.some((t) => t.id !== id && t.spec?.id === specID)
|
||||
if (!stillReferenced) await api.sjDeleteSpec(specID).catch(() => {})
|
||||
}
|
||||
|
||||
store.removeTab(id)
|
||||
}
|
||||
|
||||
/** requestUnload confirms only when something would actually be lost. */
|
||||
function requestUnload(id: string) {
|
||||
const target = useSJStore.getState().tabs.find((t) => t.id === id)
|
||||
if (!target) return
|
||||
const losses = tabLosses(target)
|
||||
if (!anyLoss(losses)) {
|
||||
void unloadDocument(id)
|
||||
return
|
||||
}
|
||||
setConfirming({ id, name: target.name, losses })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-surface-card border-b border-border shrink-0 flex-wrap">
|
||||
<DocumentPicker
|
||||
tab={tab}
|
||||
tabs={tabs}
|
||||
onOpenDoc={onOpenDoc}
|
||||
onLoadNew={onLoadNew}
|
||||
onUnload={requestUnload}
|
||||
/>
|
||||
|
||||
{spec ? (
|
||||
<>
|
||||
{spec.servers.length > 1 && (
|
||||
<select
|
||||
value={tab.serverIndex}
|
||||
onChange={(e) => {
|
||||
const i = Number(e.target.value)
|
||||
const srv = spec.servers[i]
|
||||
store.updateTab(tab.id, {
|
||||
serverIndex: i,
|
||||
scheme: srv.scheme || 'https',
|
||||
host: srv.host || '',
|
||||
basePath: srv.basePath ?? '',
|
||||
})
|
||||
}}
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border"
|
||||
title="This document declares more than one server"
|
||||
>
|
||||
{spec.servers.map((s, i) => (
|
||||
// Labelled with the resolved address, not the raw url: a document
|
||||
// using server variables writes "{protocol}://{host}" there, and
|
||||
// picking between two of those says nothing about where the
|
||||
// request goes.
|
||||
<option key={i} value={i} title={s.url}>
|
||||
{s.host ? `${s.scheme || 'https'}://${s.host}${s.basePath || ''}` : s.url}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={tab.scheme}
|
||||
onChange={(e) => store.updateTab(tab.id, { scheme: e.target.value })}
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border"
|
||||
>
|
||||
<option value="https">https</option>
|
||||
<option value="http">http</option>
|
||||
</select>
|
||||
<input
|
||||
value={tab.host}
|
||||
onChange={(e) => store.updateTab(tab.id, { host: e.target.value })}
|
||||
placeholder="host"
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border flex-1 min-w-40 max-w-xs"
|
||||
/>
|
||||
<input
|
||||
value={tab.basePath}
|
||||
onChange={(e) => store.updateTab(tab.id, { basePath: e.target.value })}
|
||||
placeholder="/base"
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border w-28"
|
||||
/>
|
||||
{/* Reaches every sub-tab from here, so a run and a single send agree.
|
||||
A User-Agent set on an auth profile outranks it. */}
|
||||
<input
|
||||
value={tab.userAgent}
|
||||
onChange={(e) => store.updateTab(tab.id, { userAgent: e.target.value })}
|
||||
placeholder="User-Agent (default: Chrome)"
|
||||
title="Sent with every request from this document. Empty sends a browser default."
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border w-64"
|
||||
/>
|
||||
|
||||
<PlaceholderControl spec={spec} />
|
||||
|
||||
{/* The profile a single send and an Automate run authenticate as. The
|
||||
Auth Matrix picks its own set, since it runs several at once. */}
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-muted ml-auto">
|
||||
Auth
|
||||
<select
|
||||
value={tab.activeProfileId}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__manage') {
|
||||
onOpenProfiles()
|
||||
return
|
||||
}
|
||||
store.updateTab(tab.id, { activeProfileId: e.target.value })
|
||||
}}
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border"
|
||||
>
|
||||
<option value="">none</option>
|
||||
{tab.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label || p.id}
|
||||
</option>
|
||||
))}
|
||||
<option value="__manage">Manage profiles…</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{diagnostics > 0 && (
|
||||
<button
|
||||
onClick={() => onOpenDoc('diagnostics')}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-sm text-xs text-semantic-warning hover:bg-surface-input"
|
||||
title="Some of this document could not be resolved"
|
||||
>
|
||||
<AlertTriangle size={13} /> {diagnostics}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-content-muted">
|
||||
No document loaded - load one, or use Brute to find one.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rendered from the bar, not the dropdown: the panel closes on any
|
||||
outside mousedown, so a modal inside it would dismiss itself. */}
|
||||
{confirming && (
|
||||
<ConfirmModal
|
||||
title={`Unload ${confirming.name}?`}
|
||||
message="Its auth profiles, edited requests and run results are discarded. The document itself can be loaded again - the fetch that retrieved it is in History."
|
||||
body={<LossList losses={confirming.losses} />}
|
||||
confirmLabel="Unload"
|
||||
deliberate
|
||||
onConfirm={() => {
|
||||
const id = confirming.id
|
||||
setConfirming(null)
|
||||
void unloadDocument(id)
|
||||
}}
|
||||
onClose={() => setConfirming(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** LossList names what unloading costs. Credentials are the reason this
|
||||
* confirmation exists at all: the API returns whether a profile holds a value
|
||||
* and never the value, so a token typed ten minutes ago is unrecoverable. */
|
||||
function LossList({ losses }: { losses: SJLosses }) {
|
||||
const items: string[] = []
|
||||
if (losses.profiles > 0) {
|
||||
items.push(`${losses.profiles} auth profile${losses.profiles === 1 ? '' : 's'} (credentials)`)
|
||||
}
|
||||
if (losses.drafts > 0) {
|
||||
items.push(`${losses.drafts} edited request${losses.drafts === 1 ? '' : 's'}`)
|
||||
}
|
||||
if (losses.running > 0) {
|
||||
items.push(`${losses.running} run${losses.running === 1 ? '' : 's'} still in flight`)
|
||||
}
|
||||
if (losses.results > 0) {
|
||||
items.push(`results from ${losses.results} finished run${losses.results === 1 ? '' : 's'}`)
|
||||
}
|
||||
return (
|
||||
<ul className="text-xs text-content-muted list-disc pl-4 space-y-0.5">
|
||||
{items.map((t) => (
|
||||
<li key={t}>{t}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** The document chip and its dropdown.
|
||||
*
|
||||
* Fixed-position panel, for the reason ProjectSwitcher documents: an absolutely
|
||||
* positioned one is clipped by a header that scrolls horizontally. */
|
||||
/** PlaceholderControl sets the values this document's requests are generated
|
||||
* from: the test string in the bar, the date, URL and email behind the gear.
|
||||
*
|
||||
* Committing re-parses the document server-side, because a placeholder is baked
|
||||
* into every generated default and body at parse time rather than substituted
|
||||
* per request — which is why this commits on blur and Enter and never on
|
||||
* change. Values the operator has typed into a parameter or body outrank a
|
||||
* generated one and survive the re-parse.
|
||||
*
|
||||
* The fields show the *effective* values, so clearing one and committing brings
|
||||
* the server's default back visibly. */
|
||||
function PlaceholderControl({ spec }: { spec: SJSpec }) {
|
||||
const store = useSJStore()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [draft, setDraft] = useState<SJPlaceholders>(spec.placeholders)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Re-seeded from the parse, so a change made against this document in another
|
||||
// tab is reflected here rather than overwritten by a stale draft.
|
||||
useEffect(() => setDraft(spec.placeholders), [spec.placeholders])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !btnRef.current) return
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
// Clicking away commits, exactly as blurring the string input does. Reverting
|
||||
// instead would race that blur — both fire for one click — and would make the
|
||||
// two halves of one control disagree about what dismissing it means.
|
||||
function onDown(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement
|
||||
if (!t.closest('[data-sj-phmenu]')) {
|
||||
setOpen(false)
|
||||
void commit(draft)
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(spec.placeholders)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDown, true)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown, true)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open, draft, spec.placeholders])
|
||||
|
||||
async function commit(next: SJPlaceholders) {
|
||||
const cur = spec.placeholders
|
||||
// Also the guard against the two dismiss paths firing for one click: a blur
|
||||
// and an outside-mousedown both land before the first reply does.
|
||||
if (
|
||||
saving ||
|
||||
(next.string === cur.string && next.date === cur.date &&
|
||||
next.url === cur.url && next.email === cur.email)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await api.sjSetPlaceholders(spec.id, next)
|
||||
store.setSpecParse(spec.id, res.spec)
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
setDraft(spec.placeholders)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field = 'bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-content-muted" data-sj-phmenu>
|
||||
<label className="flex items-center gap-1.5">
|
||||
Placeholder
|
||||
<input
|
||||
value={draft.string}
|
||||
disabled={saving}
|
||||
onChange={(e) => setDraft({ ...draft, string: e.target.value })}
|
||||
onBlur={() => void commit(draft)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur()
|
||||
if (e.key === 'Escape') setDraft(spec.placeholders)
|
||||
}}
|
||||
title="Generated for every string a schema gives no example for. Changes what this document's requests contain; values you have edited are kept."
|
||||
className={`${field} w-28 text-content-primary`}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Date, URL and email placeholders"
|
||||
className="p-1.5 rounded-sm border border-border bg-surface-input hover:text-content-primary hover:border-accent-secondary transition-colors"
|
||||
>
|
||||
<SlidersHorizontal size={13} strokeWidth={1.8} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-sj-phmenu
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
className="fixed z-50 w-72 p-2 rounded-sm bg-surface-card border border-border shadow-lg flex flex-col gap-2"
|
||||
>
|
||||
{([
|
||||
['date', 'Date', '1990-01-01'],
|
||||
['url', 'URL', 'https://bishopfox.com'],
|
||||
['email', 'Email', '[email protected]'],
|
||||
] as const).map(([key, label, hint]) => (
|
||||
<label key={key} className="flex flex-col gap-1 text-[11px] text-content-muted">
|
||||
{label}
|
||||
<input
|
||||
value={draft[key]}
|
||||
disabled={saving}
|
||||
placeholder={hint}
|
||||
onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}
|
||||
className={`${field} w-full text-content-primary`}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<p className="text-[10px] text-content-muted">
|
||||
Used where a property name or schema format asks for that shape. Empty restores
|
||||
the default.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
void commit(draft)
|
||||
}}
|
||||
className="self-end px-2 py-1 rounded-sm text-[11px] bg-accent-secondary text-black hover:bg-accent-secondary-hover transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentPicker({
|
||||
tab,
|
||||
tabs,
|
||||
onOpenDoc,
|
||||
onLoadNew,
|
||||
onUnload,
|
||||
}: {
|
||||
tab: SJTab
|
||||
tabs: SJTab[]
|
||||
onOpenDoc: (docTab: DocTab) => void
|
||||
onLoadNew: () => void
|
||||
onUnload: (tabId: string) => void
|
||||
}) {
|
||||
const store = useSJStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !btnRef.current) return
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDown(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement
|
||||
if (!t.closest('[data-sj-docmenu]')) setOpen(false)
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown, true)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown, true)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const spec = tab.spec
|
||||
const label = spec ? spec.title : tab.loading ? 'Loading…' : 'No document'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
data-sj-docmenu
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Choose or load a document"
|
||||
className="flex items-center gap-1.5 max-w-xs px-2 py-1.5 rounded-sm text-xs bg-surface-input border border-border text-content-secondary hover:text-content-primary hover:border-accent-secondary transition-colors"
|
||||
>
|
||||
<FileJson size={14} strokeWidth={1.8} className="shrink-0" aria-hidden="true" />
|
||||
<span className={`truncate ${spec ? 'text-content-primary' : 'italic text-content-muted'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{spec && (
|
||||
<span className="text-content-muted shrink-0">
|
||||
{spec.version} · {spec.operations.length} ops
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={12} className="shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-sj-docmenu
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left }}
|
||||
className="w-72 z-50 bg-surface-card border border-border rounded shadow-lg py-1 text-xs"
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-content-muted">
|
||||
Documents
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{tabs.map((t) => {
|
||||
const running = [t.scanRun, t.matrixRun, t.discoverRun].some(
|
||||
(r) => r?.status === 'running'
|
||||
)
|
||||
return (
|
||||
// Two sibling buttons in a div, not one nested in the other. The
|
||||
// close control used to be a span[role=button] inside the row
|
||||
// button: interactive content inside a <button>, which is invalid
|
||||
// and left it mouse-only and fragile to hit.
|
||||
<div key={t.id} className="w-full flex items-center hover:bg-surface-hover">
|
||||
<button
|
||||
onClick={() => {
|
||||
store.setActiveTab(t.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-1.5 text-left"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||
t.id === tab.id ? 'bg-accent-secondary' : 'bg-transparent'
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate flex-1 text-content-secondary">{t.name}</span>
|
||||
{running && (
|
||||
<Circle size={7} className="text-semantic-success fill-current shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{/* Always rendered. Gating this on more than one document meant
|
||||
the usual case — one loaded — had no way to remove it. */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onUnload(t.id)
|
||||
}}
|
||||
className="px-2 py-1.5 text-content-muted hover:text-semantic-error shrink-0"
|
||||
title={`Unload ${t.name}`}
|
||||
aria-label={`Unload ${t.name}`}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t border-border-subtle mt-1 pt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onLoadNew()
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface-hover text-left text-content-secondary"
|
||||
>
|
||||
<Plus size={12} /> Load a document…
|
||||
</button>
|
||||
{tab.spec && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onOpenDoc('info')
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface-hover text-left text-content-secondary"
|
||||
>
|
||||
<FileJson size={12} /> About this document
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onUnload(tab.id)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface-hover text-left text-content-secondary hover:text-semantic-error"
|
||||
>
|
||||
<X size={12} /> Unload this document
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Upload, Link2, ClipboardPaste, AlertTriangle, Search } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { b64DecodeUTF8 } from '../../lib/bytes'
|
||||
import { useSJStore, type SJTab } from '../../stores/sjStore'
|
||||
import TabButton from '../TabButton'
|
||||
|
||||
type Source = 'url' | 'file' | 'paste'
|
||||
export type DocTab = 'load' | 'info' | 'diagnostics' | 'source'
|
||||
|
||||
/** Everything about the document in one place: how to load one, what was in it,
|
||||
* what could not be resolved, and the bytes themselves. It was a sub-tab, which
|
||||
* meant a whole working view was spent on a screen you visit once. */
|
||||
export default function SJDocumentModal({
|
||||
tab,
|
||||
initialTab,
|
||||
onFailure,
|
||||
onClose,
|
||||
onBrute,
|
||||
}: {
|
||||
tab: SJTab
|
||||
initialTab: DocTab
|
||||
onFailure: (tabId: string, e: unknown) => void
|
||||
onClose: () => void
|
||||
onBrute: () => void
|
||||
}) {
|
||||
const store = useSJStore()
|
||||
const spec = tab.spec
|
||||
const [docTab, setDocTab] = useState<DocTab>(initialTab)
|
||||
const [source, setSource] = useState<Source>('url')
|
||||
const [text, setText] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [specSource, setSpecSource] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (docTab !== 'source' || !spec || specSource) return
|
||||
api
|
||||
.sjGetSpecSource(spec.id)
|
||||
.then((res) => setSpecSource(b64DecodeUTF8(res.source)))
|
||||
.catch(() => setSpecSource('(could not read the document source)'))
|
||||
}, [docTab, spec, specSource])
|
||||
|
||||
// Read client-side, like the fuzzer's wordlist loader: the bytes have to reach
|
||||
// the server anyway, and a plain JSON post needs no multipart handling.
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setText(String(reader.result ?? ''))
|
||||
setFileName(file.name)
|
||||
setSource('file')
|
||||
}
|
||||
reader.readAsText(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
store.updateTab(tab.id, { loading: true, loadError: '', htmlHint: null })
|
||||
try {
|
||||
const body =
|
||||
source === 'url' ? { url: url.trim() } : { text, name: fileName || undefined }
|
||||
const res = await api.sjLoad(body)
|
||||
store.setSpec(tab.id, res.spec)
|
||||
setSpecSource('')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
onFailure(tab.id, e)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const diagnostics = spec?.diagnostics ?? []
|
||||
const canLoad = source === 'url' ? !!url.trim() : !!text.trim()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col w-full max-w-5xl h-[85vh] bg-surface-card border border-border rounded shadow-lg overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<span className="text-xs font-semibold text-content-primary uppercase tracking-wide">
|
||||
{spec ? spec.title : 'Load a document'}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<TabButton active={docTab === 'load'} onClick={() => setDocTab('load')}>
|
||||
Load
|
||||
</TabButton>
|
||||
{spec && (
|
||||
<>
|
||||
<TabButton active={docTab === 'info'} onClick={() => setDocTab('info')}>
|
||||
Info
|
||||
</TabButton>
|
||||
<TabButton active={docTab === 'diagnostics'} onClick={() => setDocTab('diagnostics')}>
|
||||
Diagnostics{diagnostics.length > 0 ? ` (${diagnostics.length})` : ''}
|
||||
</TabButton>
|
||||
<TabButton active={docTab === 'source'} onClick={() => setDocTab('source')}>
|
||||
Source
|
||||
</TabButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
{docTab === 'load' && (
|
||||
<div className="max-w-xl space-y-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['url', 'file', 'paste'] as Source[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSource(s)}
|
||||
className={`px-3 py-1.5 rounded-sm text-xs transition-colors ${
|
||||
source === s
|
||||
? 'bg-accent text-content-primary'
|
||||
: 'text-content-secondary hover:text-content-primary hover:bg-surface-input'
|
||||
}`}
|
||||
>
|
||||
{s === 'url' ? 'URL' : s === 'file' ? 'File' : 'Paste'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{source === 'url' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={14} className="text-content-muted shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && canLoad && void load()}
|
||||
placeholder="https://target/api/v3/openapi.json"
|
||||
className="flex-1 bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[11px] text-content-muted">
|
||||
Fetched through Joro's proxy, so the retrieval is captured in History and
|
||||
obeys scope and Match & Replace like any other request.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === 'file' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="inline-flex items-center gap-2 px-3 py-1.5 rounded-sm bg-surface-input border border-border cursor-pointer text-xs hover:bg-surface-hover">
|
||||
<Upload size={14} />
|
||||
<span>{fileName || 'Choose a file…'}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.yaml,.yml,application/json,text/yaml"
|
||||
onChange={handleFile}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{text && (
|
||||
<div className="text-[11px] text-content-muted">
|
||||
{text.length.toLocaleString()} bytes read
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source === 'paste' && (
|
||||
<div className="flex items-start gap-2">
|
||||
<ClipboardPaste size={14} className="text-content-muted shrink-0 mt-2" />
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Paste a Swagger 2.0 or OpenAPI 3.x document"
|
||||
className="flex-1 h-48 bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={tab.loading || !canLoad}
|
||||
className="px-4 py-1.5 rounded-sm bg-accent-secondary hover:bg-accent-secondary-hover text-black text-xs font-semibold disabled:opacity-50"
|
||||
>
|
||||
{tab.loading ? 'Loading…' : 'Load document'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose()
|
||||
onBrute()
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs text-accent-secondary hover:underline"
|
||||
>
|
||||
<Search size={12} /> Don't have one? Brute-force for it
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab.loadError && (
|
||||
<div className="flex items-start gap-2 text-xs text-semantic-error">
|
||||
<AlertTriangle size={14} className="shrink-0 mt-px" />
|
||||
<span>{tab.loadError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{docTab === 'info' && spec && (
|
||||
<div className="max-w-xl space-y-1 text-xs">
|
||||
<Row label="Title" value={spec.title} />
|
||||
<Row label="Format" value={`${spec.format} ${spec.version}`} />
|
||||
{spec.apiVersion && <Row label="API version" value={spec.apiVersion} />}
|
||||
<Row label="Operations" value={String(spec.operations.length)} />
|
||||
<Row label="Servers" value={String(spec.servers.length)} />
|
||||
<Row label="Security schemes" value={String(spec.auth.length)} />
|
||||
<Row label="Size" value={`${spec.sizeBytes.toLocaleString()} bytes`} />
|
||||
{spec.sourceUrl && <Row label="Source" value={spec.sourceUrl} />}
|
||||
{spec.description && (
|
||||
<div className="pt-2 text-[11px] text-content-muted whitespace-pre-wrap">
|
||||
{spec.description.slice(0, 2000)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{docTab === 'diagnostics' && spec && (
|
||||
<div>
|
||||
{diagnostics.length === 0 ? (
|
||||
<div className="text-xs text-content-muted">
|
||||
Everything in this document resolved.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] text-content-muted mb-2 max-w-3xl">
|
||||
These schemas were degraded to an empty object, so a request built from them
|
||||
may be rejected for reasons that have nothing to do with authorization.
|
||||
</div>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{diagnostics.slice(0, 500).map((d, i) => (
|
||||
<tr key={i} className="border-b border-border-subtle last:border-0">
|
||||
<td className="py-1 pr-2 text-semantic-warning align-top whitespace-nowrap">
|
||||
{d.kind}
|
||||
</td>
|
||||
<td className="py-1 pr-2 font-mono text-content-secondary break-all">
|
||||
{d.ref}
|
||||
</td>
|
||||
<td className="py-1 font-mono text-content-muted break-all">{d.pointer}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{docTab === 'source' && (
|
||||
<pre className="text-[11px] font-mono text-content-secondary whitespace-pre-wrap break-all">
|
||||
{specSource ? specSource.slice(0, 400_000) : 'Loading…'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="text-content-muted w-32 shrink-0">{label}</span>
|
||||
<span className="text-content-secondary break-all">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Play, Square, Unlock, TriangleAlert, Equal, Settings2 } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useSJStore, resolveScope, type SJTab, type SJProfile } from '../../stores/sjStore'
|
||||
import type { SJMatrixRow } from '../../lib/sjTypes'
|
||||
import { verdictPill, methodClass } from '../../lib/sjStatus'
|
||||
import { useResizable } from '../../lib/useResizable'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import SJScopePicker, { scopeLabel } from './SJScopePicker'
|
||||
import SJResultDetail from './SJResultDetail'
|
||||
|
||||
export default function SJMatrixPanel({
|
||||
tab,
|
||||
onOpenProfiles,
|
||||
}: {
|
||||
tab: SJTab
|
||||
onOpenProfiles: () => void
|
||||
}) {
|
||||
const store = useSJStore()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const [detail, setDetail] = useState<{ runId: string; index: number } | null>(null)
|
||||
const split = useResizable('vertical', 0.7)
|
||||
const spec = tab.spec!
|
||||
const ops = spec.operations
|
||||
const run = tab.matrixRun
|
||||
const matrix = run?.matrix ?? null
|
||||
|
||||
function setProfiles(next: SJProfile[]) {
|
||||
store.updateTab(tab.id, { profiles: next })
|
||||
// Pushed immediately: credentials live only on the server for the session,
|
||||
// and the run reads them from there.
|
||||
void api
|
||||
.sjSetProfiles(spec.id, next.map((p, i) => ({ ...p, rank: i })))
|
||||
.catch((e) => addToast(String((e as Error).message ?? e), 'error'))
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const enabled = tab.profiles.filter((p) => p.enabled)
|
||||
if (enabled.length < 2) {
|
||||
addToast('A matrix compares authentication states - enable at least two profiles', 'error')
|
||||
onOpenProfiles()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.sjScan({
|
||||
specId: spec.id,
|
||||
operationIds: resolveScope(tab.scope, ops),
|
||||
profileIds: enabled.map((p) => p.id),
|
||||
serverIndex: tab.serverIndex,
|
||||
scheme: tab.scheme,
|
||||
host: tab.host,
|
||||
basePath: tab.basePath,
|
||||
concurrency: tab.concurrency,
|
||||
ratePerSec: tab.ratePerSec,
|
||||
userAgent: tab.userAgent || undefined,
|
||||
allowDestructive: tab.allowDestructive,
|
||||
})
|
||||
store.startRun(tab.id, 'matrixRun', res.runId, res.kind, res.total, res.warnings ?? [])
|
||||
setDetail(null)
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMatrix(id: string) {
|
||||
try {
|
||||
const view = await api.sjGetMatrix(id)
|
||||
store.updateRun(id, { matrix: view })
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// The grid builds itself when the run finishes. It used to need a click on a
|
||||
// button the operator had no reason to expect, and a matrix fetched mid-run
|
||||
// reports unsent operations as errors, so there is nothing to gain by asking.
|
||||
useEffect(() => {
|
||||
if (!run || run.status === 'running' || run.matrix) return
|
||||
void loadMatrix(run.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [run?.id, run?.status, run?.matrix])
|
||||
|
||||
const enabled = tab.profiles.filter((p) => p.enabled)
|
||||
const profiles = matrix?.profiles ?? enabled
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Profiles as chips: which authentication states this run compares, in
|
||||
privilege order. Editing them is one click away rather than a panel
|
||||
that only existed here. */}
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border shrink-0 flex-wrap text-xs">
|
||||
<span className="text-content-muted">Profiles</span>
|
||||
{tab.profiles.length === 0 ? (
|
||||
<span className="text-content-muted italic">none defined</span>
|
||||
) : (
|
||||
tab.profiles.map((p, i) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
const next = tab.profiles.slice()
|
||||
next[i] = { ...p, enabled: !p.enabled }
|
||||
setProfiles(next)
|
||||
}}
|
||||
title={p.enabled ? 'Included in the matrix' : 'Excluded'}
|
||||
className={`px-2 py-1 rounded-sm transition-colors ${
|
||||
p.enabled
|
||||
? 'bg-accent text-content-primary'
|
||||
: 'bg-surface-input text-content-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{i}. {p.label || p.id}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
onClick={onOpenProfiles}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary"
|
||||
>
|
||||
<Settings2 size={12} /> Manage…
|
||||
</button>
|
||||
<span className="text-[10px] text-content-muted">least to most privileged</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border shrink-0 flex-wrap text-xs">
|
||||
<SJScopePicker
|
||||
scope={tab.scope}
|
||||
ops={ops}
|
||||
disabled={run?.status === 'running'}
|
||||
onChange={(next) => store.updateTab(tab.id, { scope: next })}
|
||||
/>
|
||||
<span className="text-content-muted">× {enabled.length} profiles</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{run && run.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
store.clearRun(tab.id, 'matrixRun')
|
||||
setDetail(null)
|
||||
}}
|
||||
className="px-2 py-1 text-content-muted hover:text-content-primary"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{run?.status === 'running' ? (
|
||||
<button
|
||||
onClick={() => void api.sjStopRun(run.id).catch(() => {})}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-sm bg-semantic-error-bg text-content-primary font-semibold"
|
||||
>
|
||||
<Square size={11} /> Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void start()}
|
||||
disabled={!tab.host}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-sm bg-accent-tertiary text-black font-semibold disabled:opacity-50"
|
||||
>
|
||||
<Play size={11} /> Run matrix
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-border shrink-0 text-[10px]">
|
||||
<div className="h-1.5 flex-1 bg-surface-input rounded-sm overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-secondary"
|
||||
style={{ width: `${run.total ? (run.completed / run.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-content-muted tabular-nums">
|
||||
{run.completed}/{run.total}
|
||||
</span>
|
||||
<span className="text-content-secondary">{run.status}</span>
|
||||
<span className="text-content-muted">· {scopeLabel(tab.scope, ops)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={split.containerRef} className="flex flex-col flex-1 min-h-0">
|
||||
<div
|
||||
style={{ flex: detail ? split.fraction : 1 }}
|
||||
className="min-h-0 overflow-auto"
|
||||
>
|
||||
{!matrix ? (
|
||||
<div className="p-3 text-xs text-content-muted">
|
||||
{tab.profiles.length < 2
|
||||
? 'Define at least two auth profiles, then run the matrix. A cell that matches a higher-privilege cell while a lower one is refused is what broken access control looks like.'
|
||||
: run?.status === 'running'
|
||||
? 'Running…'
|
||||
: 'Run the matrix to build the grid.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="text-xs w-full">
|
||||
<thead className="sticky top-0 bg-surface-card z-10">
|
||||
<tr className="text-content-muted text-left">
|
||||
<th className="px-2 py-1.5 font-normal sticky left-0 bg-surface-card">Operation</th>
|
||||
{profiles.map((p) => (
|
||||
<th key={p.id} className="px-2 py-1.5 font-normal w-24">
|
||||
{p.label || p.id}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-2 py-1.5 font-normal w-32">Verdict</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(matrix.rows ?? []).map((row) => (
|
||||
<MatrixRow
|
||||
key={row.opId}
|
||||
row={row}
|
||||
onCell={(i) => setDetail({ runId: run!.id, index: i })}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
<div className="drag-handle-v" {...split.handleProps} />
|
||||
<div style={{ flex: 1 - split.fraction }} className="min-h-0">
|
||||
<SJResultDetail runId={detail.runId} index={detail.index} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{matrix && (
|
||||
<div className="flex items-center gap-3 px-2 py-1 border-t border-border shrink-0 text-[10px] text-content-muted flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<Unlock size={10} className="text-semantic-error" /> served without credentials
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<TriangleAlert size={10} className="text-semantic-error" /> lower privilege reached a
|
||||
higher-privilege response
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Equal size={10} /> every profile got the same answer
|
||||
</span>
|
||||
{matrix.interesting.length > 0 && (
|
||||
<span className="ml-auto text-semantic-error">
|
||||
{matrix.interesting.length} worth reading
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatrixRow({ row, onCell }: { row: SJMatrixRow; onCell: (index: number) => void }) {
|
||||
const cells = row.cells ?? []
|
||||
const identical =
|
||||
cells.length > 1 &&
|
||||
cells.every((c) => c.status === cells[0].status && c.len === cells[0].len && !c.skipped)
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`border-b border-border-subtle ${
|
||||
row.verdict === 'open' || row.verdict === 'broken' ? 'border-l-2 border-l-semantic-error' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-1 sticky left-0 bg-surface-body">
|
||||
<span className={`font-bold text-[10px] mr-1.5 ${methodClass(row.method)}`}>{row.method}</span>
|
||||
<span className="font-mono text-content-secondary">{row.path}</span>
|
||||
</td>
|
||||
{cells.map((c) => (
|
||||
<td
|
||||
key={c.profileId}
|
||||
onClick={() => onCell(c.index)}
|
||||
className="px-2 py-1 cursor-pointer hover:bg-surface-hover tabular-nums"
|
||||
title={c.skipped ? `not sent: ${c.skipped}` : c.error || `${c.len} bytes, ${c.ms} ms`}
|
||||
>
|
||||
{c.skipped ? (
|
||||
<span className="text-content-muted">-</span>
|
||||
) : (
|
||||
<span className="text-content-secondary">{c.status}</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-2 py-1">
|
||||
<span className="flex items-center gap-1">
|
||||
{row.verdict === 'open' && <Unlock size={11} className="text-semantic-error" />}
|
||||
{row.verdict === 'broken' && <TriangleAlert size={11} className="text-semantic-error" />}
|
||||
{identical && row.verdict !== 'open' && row.verdict !== 'broken' && (
|
||||
<Equal size={11} className="text-content-muted" />
|
||||
)}
|
||||
{verdictPill(row.verdict, row.detail)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { ChevronRight, Send, RotateCcw, AlertTriangle } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { useSJStore, makeDraft, type SJTab, type SJDraft } from '../../stores/sjStore'
|
||||
import type { SJOperation, SJParam } from '../../lib/sjTypes'
|
||||
import { useResizable } from '../../lib/useResizable'
|
||||
import { b64DecodeUTF8 as b64Decode, b64EncodeUTF8 as b64Encode } from '../../lib/bytes'
|
||||
import { methodClass, triagePill, formatSize } from '../../lib/sjStatus'
|
||||
import { ResponseRender, usePrettyJson } from '../ResponseRender'
|
||||
import TabButton from '../TabButton'
|
||||
import ContextMenu, { type MenuItem } from '../ContextMenu'
|
||||
import { getSelectionMenuItems } from '../../lib/selectionMenu'
|
||||
import { copyText } from '../../lib/clipboard'
|
||||
import { rawToCurl } from '../../lib/httpTransform'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import SJParamRow from './SJParamRow'
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 250
|
||||
|
||||
export default function SJOperationsView({ tab }: { tab: SJTab }) {
|
||||
const store = useSJStore()
|
||||
const navigate = useNavigate()
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const hSplit = useResizable('horizontal', 0.22)
|
||||
const vSplit = useResizable('horizontal', 0.56)
|
||||
const [prettyJson, setPrettyJson] = usePrettyJson()
|
||||
const [respTab, setRespTab] = useState<'render' | 'raw'>('render')
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null)
|
||||
const renderTimer = useRef<number | null>(null)
|
||||
const renderCtrl = useRef<AbortController | null>(null)
|
||||
|
||||
const spec = tab.spec!
|
||||
const op = spec.operations.find((o) => o.id === tab.selectedOpId) ?? null
|
||||
const draft = (op && tab.drafts[op.id]) || makeDraft()
|
||||
|
||||
const groups = useMemo(() => groupOperations(spec.operations, tab), [spec.operations, tab.groupBy, tab.opFilter, tab.methodFilter])
|
||||
|
||||
/** The methods this document actually declares, so the filter row offers only
|
||||
* what is there rather than a fixed list of eight. */
|
||||
const methodsPresent = useMemo(() => {
|
||||
const seen = new Set(spec.operations.map((o) => o.method))
|
||||
return [...seen].sort()
|
||||
}, [spec.operations])
|
||||
|
||||
/** sj's `endpoints` command: raw routes, one per line, for piping into other
|
||||
* tooling. Unique and sorted, since a path with three methods is one route. */
|
||||
function copyEndpoints() {
|
||||
const base = tab.basePath ?? ''
|
||||
const routes = [...new Set(spec.operations.map((o) => base + o.path))].sort()
|
||||
void copyText(routes.join('\n'))
|
||||
addToast(`Copied ${routes.length} endpoints`, 'info')
|
||||
}
|
||||
|
||||
/** Copy as curl, matching what every other request-bearing tab offers. The
|
||||
* bytes are whatever would actually be sent, so an edited raw request copies
|
||||
* as the operator edited it. */
|
||||
async function copyAsCurl() {
|
||||
if (!op) return
|
||||
try {
|
||||
const raw = draft.rawDirty
|
||||
? draft.rawEdited
|
||||
: b64Decode((await api.sjRender(renderBody(op))).raw)
|
||||
const url = `${tab.scheme}://${tab.host}${firstLineTarget(raw)}`
|
||||
void copyText(rawToCurl(raw, url))
|
||||
addToast('Copied as curl', 'info')
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function patchDraft(updates: Partial<SJDraft>) {
|
||||
if (!op) return
|
||||
store.updateDraft(tab.id, op.id, updates)
|
||||
}
|
||||
|
||||
// Render is a preview, not a send path — see send(). It runs only while the
|
||||
// Raw pane is open, so typing in the form costs nothing.
|
||||
useEffect(() => {
|
||||
if (!op || draft.view !== 'raw' || draft.rawDirty) return
|
||||
if (renderTimer.current) window.clearTimeout(renderTimer.current)
|
||||
renderTimer.current = window.setTimeout(() => void doRender(), RENDER_DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (renderTimer.current) window.clearTimeout(renderTimer.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [op?.id, draft.view, draft.rawDirty, JSON.stringify(draft.values), JSON.stringify(draft.omit), draft.contentType, draft.profileId, tab.scheme, tab.host, tab.basePath, tab.userAgent])
|
||||
|
||||
async function doRender() {
|
||||
if (!op) return
|
||||
renderCtrl.current?.abort()
|
||||
const ctrl = new AbortController()
|
||||
renderCtrl.current = ctrl
|
||||
const seq = draft.renderSeq + 1
|
||||
patchDraft({ rendering: true, renderSeq: seq, renderError: '' })
|
||||
try {
|
||||
const res = await api.sjRender(renderBody(op))
|
||||
// A reply older than the newest request is dropped, so a slow render
|
||||
// cannot overwrite a newer one.
|
||||
const cur = useSJStore.getState().tabs.find((t) => t.id === tab.id)?.drafts[op.id]
|
||||
if (cur && cur.renderSeq > seq) return
|
||||
store.updateDraft(tab.id, op.id, { rendered: b64Decode(res.raw), rendering: false })
|
||||
} catch (e) {
|
||||
store.updateDraft(tab.id, op.id, { rendering: false, renderError: String((e as Error).message ?? e) })
|
||||
}
|
||||
}
|
||||
|
||||
function renderBody(o: SJOperation) {
|
||||
const values: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(draft.values)) {
|
||||
if (v !== undefined) values[k] = v
|
||||
}
|
||||
if (draft.contentType) values.contentType = draft.contentType
|
||||
const bodyText = draft.bodyByContentType[draft.contentType || defaultContentType(o)]
|
||||
if (bodyText !== undefined) values.body = bodyText
|
||||
return {
|
||||
specId: spec.id,
|
||||
operationId: o.id,
|
||||
serverIndex: tab.serverIndex,
|
||||
scheme: tab.scheme,
|
||||
host: tab.host,
|
||||
basePath: tab.basePath,
|
||||
values,
|
||||
omit: Object.keys(draft.omit),
|
||||
profileId: draft.profileId || tab.activeProfileId,
|
||||
userAgent: tab.userAgent || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!op) return
|
||||
patchDraft({ sending: true, error: '' })
|
||||
try {
|
||||
// When the bytes are clean the server renders again and sends in one call,
|
||||
// so a stale or in-flight preview can never become the request that goes
|
||||
// out. When they are dirty they go verbatim, and the profile is NOT
|
||||
// re-applied: it is already baked into what the operator edited.
|
||||
const body = draft.rawDirty
|
||||
? { ...renderBody(op), raw: b64Encode(draft.rawEdited), profileId: '' }
|
||||
: renderBody(op)
|
||||
const res = await api.sjSend(body)
|
||||
patchDraft({
|
||||
sending: false,
|
||||
response: b64Decode(res.rawResp),
|
||||
status: res.status,
|
||||
durationMs: res.durationMs,
|
||||
seq: res.seq,
|
||||
requestId: res.requestId ?? '',
|
||||
seqNote: res.seqNote ?? '',
|
||||
triage: res.triage,
|
||||
rendered: draft.rawDirty ? draft.rendered : b64Decode(res.raw),
|
||||
})
|
||||
} catch (e) {
|
||||
patchDraft({ sending: false, error: String((e as Error).message ?? e) })
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToFuzz(param?: SJParam) {
|
||||
if (!op) return
|
||||
try {
|
||||
// The marker is placed server-side, after encoding. Searching the rendered
|
||||
// bytes for the value would break on a percent-encoded value, on one that
|
||||
// appears twice, and on one inside a JSON body.
|
||||
const res = await api.sjRender({
|
||||
...renderBody(op),
|
||||
fuzzParam: param ? `${param.in}:${param.name}` : undefined,
|
||||
fuzzKeyword: param ? '§' : undefined,
|
||||
})
|
||||
navigate('/fuzz', { state: { scheme: res.scheme, host: res.host, rawReq: res.raw } })
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToManipulate() {
|
||||
if (!op) return
|
||||
try {
|
||||
const res = draft.rawDirty
|
||||
? { raw: b64Encode(draft.rawEdited), scheme: tab.scheme, host: tab.host }
|
||||
: await api.sjRender(renderBody(op))
|
||||
navigate('/manipulate', { state: { scheme: res.scheme, host: res.host, rawReq: res.raw } })
|
||||
} catch (e) {
|
||||
addToast(String((e as Error).message ?? e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function paramMenu(param: SJParam): MenuItem[] {
|
||||
const key = `${param.in}:${param.name}`
|
||||
return [
|
||||
...getSelectionMenuItems(navigate),
|
||||
{ label: 'Send to Fuzz - mark here', onClick: () => void sendToFuzz(param) },
|
||||
{ label: 'Reset to generated value', onClick: () => patchDraft({ values: { ...draft.values, [key]: param.default } }) },
|
||||
{ label: 'Clear value', onClick: () => patchDraft({ values: { ...draft.values, [key]: '' } }) },
|
||||
{ label: 'Copy parameter name', onClick: () => void copyText(param.name) },
|
||||
]
|
||||
}
|
||||
|
||||
const bodies = op?.bodies ?? []
|
||||
const activeCT = draft.contentType || (bodies[0]?.contentType ?? '')
|
||||
const activeBody =
|
||||
draft.bodyByContentType[activeCT] ??
|
||||
(bodies.find((b) => b.contentType === activeCT)?.content
|
||||
? b64Decode(bodies.find((b) => b.contentType === activeCT)!.content)
|
||||
: '')
|
||||
|
||||
return (
|
||||
<div ref={hSplit.containerRef} className="flex flex-1 min-h-0">
|
||||
{/* Operation tree */}
|
||||
<div style={{ flex: hSplit.fraction }} className="min-w-0 flex flex-col border-r border-border">
|
||||
<div className="p-1.5 border-b border-border shrink-0 space-y-1.5">
|
||||
<input
|
||||
value={tab.opFilter}
|
||||
onChange={(e) => store.updateTab(tab.id, { opFilter: e.target.value })}
|
||||
placeholder="Filter operations…"
|
||||
className="w-full bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border"
|
||||
/>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{methodsPresent.map((m) => {
|
||||
const on = tab.methodFilter.includes(m)
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() =>
|
||||
store.updateTab(tab.id, {
|
||||
methodFilter: on
|
||||
? tab.methodFilter.filter((x) => x !== m)
|
||||
: [...tab.methodFilter, m],
|
||||
})
|
||||
}
|
||||
className={`px-1.5 py-0.5 rounded-sm text-[10px] font-semibold transition-colors ${
|
||||
on
|
||||
? 'bg-accent text-content-primary'
|
||||
: `bg-surface-input hover:bg-surface-hover ${methodClass(m)}`
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={tab.groupBy}
|
||||
onChange={(e) => store.updateTab(tab.id, { groupBy: e.target.value as 'tag' | 'path' })}
|
||||
className="bg-surface-input text-[10px] px-1.5 py-1 rounded-sm border border-border"
|
||||
>
|
||||
<option value="tag">by tag</option>
|
||||
<option value="path">by path</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-content-muted">{spec.operations.length} ops</span>
|
||||
<button
|
||||
onClick={copyEndpoints}
|
||||
title="Copy every route, one per line - sj's endpoints command, for piping elsewhere"
|
||||
className="ml-auto text-[10px] text-accent-secondary hover:underline"
|
||||
>
|
||||
Copy endpoints
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{groups.map(([group, ops]) => {
|
||||
const collapsed = !!tab.collapsed[group]
|
||||
return (
|
||||
<div key={group}>
|
||||
<button
|
||||
onClick={() =>
|
||||
store.updateTab(tab.id, {
|
||||
collapsed: collapsed
|
||||
? Object.fromEntries(Object.entries(tab.collapsed).filter(([k]) => k !== group))
|
||||
: { ...tab.collapsed, [group]: true },
|
||||
})
|
||||
}
|
||||
className="w-full flex items-center gap-1 px-1.5 py-1 text-[11px] text-content-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<ChevronRight size={11} className={collapsed ? '' : 'rotate-90'} />
|
||||
<span className="truncate">{group}</span>
|
||||
<span className="ml-auto text-content-muted">{ops.length}</span>
|
||||
</button>
|
||||
{!collapsed &&
|
||||
ops.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
onClick={() => store.updateTab(tab.id, { selectedOpId: o.id })}
|
||||
className={`flex items-center gap-1 pl-3 pr-1.5 py-0.5 cursor-pointer text-[11px] ${
|
||||
o.id === tab.selectedOpId ? 'bg-surface-hover' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-12 shrink-0 font-bold text-[9px] ${methodClass(o.method)}`}>{o.method}</span>
|
||||
<span className="truncate text-content-secondary" title={o.summary || o.path}>
|
||||
{o.path}
|
||||
</span>
|
||||
{o.destructive && (
|
||||
<AlertTriangle
|
||||
size={10}
|
||||
className="text-semantic-warning shrink-0 ml-auto"
|
||||
aria-label={`destructive: ${o.destructiveReasons?.join(', ')}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drag-handle-h" {...hSplit.handleProps} />
|
||||
|
||||
{/* Request + response */}
|
||||
{!op ? (
|
||||
<div className="flex-1 flex items-center justify-center text-[11px] text-content-muted">
|
||||
Select an operation.
|
||||
</div>
|
||||
) : (
|
||||
<div ref={vSplit.containerRef} className="flex flex-1 min-w-0">
|
||||
<div style={{ flex: vSplit.fraction }} className="min-w-0 flex flex-col">
|
||||
<div className="px-2 py-1.5 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold text-xs ${methodClass(op.method)}`}>{op.method}</span>
|
||||
<span className="text-xs font-mono text-content-primary truncate">{op.path}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{tab.profiles.length > 0 && (
|
||||
<select
|
||||
value={draft.profileId || tab.activeProfileId}
|
||||
onChange={(e) => patchDraft({ profileId: e.target.value })}
|
||||
className="bg-surface-input border border-border rounded-sm px-1 py-0.5 text-[10px]"
|
||||
>
|
||||
<option value="">no auth</option>
|
||||
{tab.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label || p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void send()}
|
||||
disabled={draft.sending}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-sm bg-accent-tertiary text-black text-[11px] font-semibold disabled:opacity-50"
|
||||
>
|
||||
<Send size={11} />
|
||||
{draft.sending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{op.summary && <div className="text-[10px] text-content-muted truncate">{op.summary}</div>}
|
||||
{op.destructive && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-semantic-warning mt-0.5">
|
||||
<AlertTriangle size={10} />
|
||||
<span>Looks destructive ({op.destructiveReasons?.join(', ')}). A scan skips it unless you allow it.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border shrink-0">
|
||||
<TabButton active={draft.view === 'form'} onClick={() => patchDraft({ view: 'form' })}>
|
||||
Form
|
||||
</TabButton>
|
||||
<TabButton active={draft.view === 'raw'} onClick={() => patchDraft({ view: 'raw' })}>
|
||||
<span className="flex items-center gap-1">
|
||||
Raw
|
||||
{draft.rawDirty && <span className="w-1.5 h-1.5 rounded-full bg-accent-secondary" />}
|
||||
</span>
|
||||
</TabButton>
|
||||
{draft.rendering && <span className="text-[10px] text-content-muted">rendering…</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{draft.view === 'form' ? (
|
||||
<FormPane
|
||||
op={op}
|
||||
draft={draft}
|
||||
activeCT={activeCT}
|
||||
activeBody={activeBody}
|
||||
onValue={(k, v) => patchDraft({ values: { ...draft.values, [k]: v } })}
|
||||
onToggle={(k, on) => {
|
||||
const next = { ...draft.omit }
|
||||
if (on) delete next[k]
|
||||
else next[k] = true
|
||||
patchDraft({ omit: next })
|
||||
}}
|
||||
onContentType={(ct) => patchDraft({ contentType: ct })}
|
||||
onBody={(text) =>
|
||||
patchDraft({ bodyByContentType: { ...draft.bodyByContentType, [activeCT]: text } })
|
||||
}
|
||||
onMenu={(x, y, param) => setMenu({ x, y, items: paramMenu(param) })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{draft.rawDirty && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-surface-input border-b border-border text-[10px] shrink-0">
|
||||
<span className="text-content-secondary">
|
||||
Raw edited - the form no longer drives this request, and the auth profile is not re-applied.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
patchDraft({ rawDirty: false, rawRetained: draft.rawEdited, rawEdited: '' })
|
||||
void doRender()
|
||||
}}
|
||||
className="ml-auto flex items-center gap-1 text-accent-secondary hover:underline"
|
||||
>
|
||||
<RotateCcw size={10} /> Revert to form
|
||||
</button>
|
||||
{draft.rawRetained && (
|
||||
<button
|
||||
onClick={() => patchDraft({ rawDirty: true, rawEdited: draft.rawRetained })}
|
||||
className="text-content-muted hover:underline"
|
||||
>
|
||||
Restore edits
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{draft.renderError && (
|
||||
<div className="px-2 py-1 text-[10px] text-semantic-error border-b border-border shrink-0">
|
||||
{draft.renderError}
|
||||
</div>
|
||||
)}
|
||||
<CodeMirror
|
||||
value={draft.rawDirty ? draft.rawEdited : draft.rendered}
|
||||
theme={oneDark}
|
||||
height="100%"
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
onChange={(v) => patchDraft({ rawDirty: true, rawEdited: v })}
|
||||
className="flex-1 min-h-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drag-handle-h" {...vSplit.handleProps} />
|
||||
|
||||
<div style={{ flex: 1 - vSplit.fraction }} className="min-w-0 flex flex-col">
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border shrink-0">
|
||||
<TabButton active={respTab === 'render'} onClick={() => setRespTab('render')}>
|
||||
Render
|
||||
</TabButton>
|
||||
<TabButton active={respTab === 'raw'} onClick={() => setRespTab('raw')}>
|
||||
Raw
|
||||
</TabButton>
|
||||
<label className="ml-auto flex items-center gap-1 text-[10px] text-content-secondary">
|
||||
<input type="checkbox" checked={prettyJson} onChange={(e) => setPrettyJson(e.target.checked)} />
|
||||
pretty
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1 relative min-h-0 overflow-auto p-2">
|
||||
{draft.error ? (
|
||||
<div className="text-[11px] text-semantic-error">{draft.error}</div>
|
||||
) : !draft.response ? (
|
||||
<div className="text-[11px] text-content-muted">No response yet.</div>
|
||||
) : respTab === 'render' ? (
|
||||
<ResponseRender raw={draft.response} prettyJson={prettyJson} />
|
||||
) : (
|
||||
<pre className="text-[10px] font-mono whitespace-pre-wrap break-all text-content-secondary">
|
||||
{draft.response}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{draft.status !== null && (
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-t border-border text-[10px] shrink-0">
|
||||
{triagePill(draft.triage ?? undefined, draft.status)}
|
||||
<span className="text-content-muted">{draft.durationMs} ms</span>
|
||||
<span className="text-content-muted">{formatSize(draft.response.length)}</span>
|
||||
{draft.requestId ? (
|
||||
<button
|
||||
onClick={() => navigate('/history', { state: { focusRequestId: draft.requestId } })}
|
||||
className="ml-auto text-accent-secondary hover:underline"
|
||||
title="Open this request in History"
|
||||
>
|
||||
seq #{draft.seq} ↗
|
||||
</button>
|
||||
) : (
|
||||
<span className="ml-auto text-content-muted" title={draft.seqNote}>
|
||||
not captured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-2 py-1 border-t border-border shrink-0">
|
||||
<button onClick={() => void sendToManipulate()} className="text-[10px] text-accent-secondary hover:underline">
|
||||
Manipulate
|
||||
</button>
|
||||
<button onClick={() => void sendToFuzz()} className="text-[10px] text-accent-secondary hover:underline">
|
||||
Fuzz
|
||||
</button>
|
||||
<button onClick={() => void copyAsCurl()} className="text-[10px] text-accent-secondary hover:underline">
|
||||
Copy as curl
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormPane({
|
||||
op, draft, activeCT, activeBody, onValue, onToggle, onContentType, onBody, onMenu,
|
||||
}: {
|
||||
op: SJOperation
|
||||
draft: SJDraft
|
||||
activeCT: string
|
||||
activeBody: string
|
||||
onValue: (key: string, v: string) => void
|
||||
onToggle: (key: string, on: boolean) => void
|
||||
onContentType: (ct: string) => void
|
||||
onBody: (text: string) => void
|
||||
onMenu: (x: number, y: number, param: SJParam) => void
|
||||
}) {
|
||||
const byIn = ['path', 'query', 'header', 'cookie'] as const
|
||||
const disabled = draft.rawDirty
|
||||
|
||||
return (
|
||||
<div className={disabled ? 'opacity-60' : ''}>
|
||||
{disabled && (
|
||||
<div className="px-2 py-1 text-[10px] text-content-muted border-b border-border-subtle">
|
||||
Send uses the raw bytes while they are edited. Revert to form to use these fields again.
|
||||
</div>
|
||||
)}
|
||||
{byIn.map((where) => {
|
||||
const params = (op.params ?? []).filter((p) => p.in === where)
|
||||
if (params.length === 0) return null
|
||||
return (
|
||||
<div key={where}>
|
||||
<div className="px-2 py-0.5 text-[10px] text-content-muted uppercase tracking-wide bg-surface-card">
|
||||
{where}
|
||||
</div>
|
||||
{params.map((p) => {
|
||||
const key = `${p.in}:${p.name}`
|
||||
return (
|
||||
<SJParamRow
|
||||
key={key}
|
||||
param={p}
|
||||
value={draft.values[key] ?? p.default}
|
||||
enabled={!draft.omit[key]}
|
||||
onChange={(v) => onValue(key, v)}
|
||||
onToggle={(on) => onToggle(key, on)}
|
||||
onMenu={(x, y) => onMenu(x, y, p)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{(op.bodies?.length ?? 0) > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-2 py-0.5 bg-surface-card">
|
||||
<span className="text-[10px] text-content-muted uppercase tracking-wide">body</span>
|
||||
{op.bodyRequired && <span className="text-semantic-error text-[10px]">*</span>}
|
||||
<select
|
||||
value={activeCT}
|
||||
onChange={(e) => onContentType(e.target.value)}
|
||||
className="ml-auto bg-surface-input border border-border rounded-sm px-1 py-0.5 text-[10px]"
|
||||
>
|
||||
{op.bodies!.map((b) => (
|
||||
<option key={b.contentType} value={b.contentType}>
|
||||
{b.contentType}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<textarea
|
||||
value={activeBody}
|
||||
onChange={(e) => onBody(e.target.value)}
|
||||
className="w-full h-44 bg-surface-input border-0 px-2 py-1 text-xs font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function defaultContentType(op: SJOperation): string {
|
||||
return op.bodies?.[0]?.contentType ?? ''
|
||||
}
|
||||
|
||||
/** groupOperations buckets the filtered operation list for the tree. */
|
||||
function groupOperations(ops: SJOperation[], tab: SJTab): [string, SJOperation[]][] {
|
||||
const filter = tab.opFilter.toLowerCase()
|
||||
const map = new Map<string, SJOperation[]>()
|
||||
for (const op of ops) {
|
||||
if (tab.methodFilter.length > 0 && !tab.methodFilter.includes(op.method)) continue
|
||||
if (
|
||||
filter &&
|
||||
!op.path.toLowerCase().includes(filter) &&
|
||||
!(op.summary ?? '').toLowerCase().includes(filter) &&
|
||||
!op.id.toLowerCase().includes(filter)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const key =
|
||||
tab.groupBy === 'tag'
|
||||
? op.tags?.[0] ?? 'untagged'
|
||||
: '/' + (op.path.split('/').filter(Boolean)[0] ?? '')
|
||||
const list = map.get(key) ?? []
|
||||
list.push(op)
|
||||
map.set(key, list)
|
||||
}
|
||||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
}
|
||||
|
||||
/** firstLineTarget pulls the request target out of raw bytes, so a curl command
|
||||
* carries the query string the render actually produced. */
|
||||
function firstLineTarget(raw: string): string {
|
||||
const line = raw.split('\r\n')[0] ?? ''
|
||||
return line.split(' ')[1] ?? '/'
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react'
|
||||
import { MoreVertical } from 'lucide-react'
|
||||
import type { SJParam } from '../../lib/sjTypes'
|
||||
import { Tooltip } from '../Tooltip'
|
||||
|
||||
/** The "Other…" option's value. NUL-prefixed so no real value collides with it,
|
||||
* and written as an escape because a literal NUL makes git treat the file as
|
||||
* binary — no diff, no blame, never reviewed. */
|
||||
const OTHER_SENTINEL = '\u0000other'
|
||||
|
||||
/** One parameter's editing row.
|
||||
*
|
||||
* The `in` badge is monochrome deliberately. severity.tsx records that
|
||||
* semantic-success collides with semantic-info or accent-tertiary depending on
|
||||
* the theme and that the three accents are reserved for selection and primary
|
||||
* actions; four more hues for four `in` values is exactly the mistake that
|
||||
* comment warns about. */
|
||||
export default function SJParamRow({
|
||||
param,
|
||||
value,
|
||||
enabled,
|
||||
onChange,
|
||||
onToggle,
|
||||
onMenu,
|
||||
}: {
|
||||
param: SJParam
|
||||
value: string
|
||||
enabled: boolean
|
||||
onChange: (v: string) => void
|
||||
onToggle: (on: boolean) => void
|
||||
onMenu: (x: number, y: number) => void
|
||||
}) {
|
||||
// An enum is a starting point, not a constraint: testing a value the document
|
||||
// says is impossible is the entire job, so the dropdown always offers a way
|
||||
// out of itself.
|
||||
const hasEnum = (param.enum?.length ?? 0) > 0
|
||||
const [freeText, setFreeText] = useState(!hasEnum || !param.enum!.includes(value))
|
||||
|
||||
const missingRequired = param.required && !enabled
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-2 py-1 border-b border-border-subtle last:border-0 ${
|
||||
missingRequired ? 'border-l-2 border-l-semantic-warning' : ''
|
||||
}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onMenu(e.clientX, e.clientY)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
className="shrink-0"
|
||||
title={
|
||||
param.required
|
||||
? 'Required by the document. Unticking it sends the request without it, which is a legitimate test.'
|
||||
: 'Include this parameter'
|
||||
}
|
||||
/>
|
||||
<span className="px-1 py-px rounded-sm text-[9px] bg-surface-input text-content-secondary shrink-0">
|
||||
{param.in}
|
||||
</span>
|
||||
<span className="text-xs text-content-primary font-semibold truncate">{param.name}</span>
|
||||
{param.required && (
|
||||
<span className={missingRequired ? 'text-semantic-warning' : 'text-semantic-error'} title="required">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-content-muted truncate">
|
||||
{param.type}
|
||||
{param.format ? `(${param.format})` : ''}
|
||||
</span>
|
||||
{param.explodedFrom && (
|
||||
<Tooltip content={`Sent as its own query parameter; the document declares it inside the object "${param.explodedFrom}"`}>
|
||||
<span className="text-[9px] text-content-muted">from {param.explodedFrom}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{param.deprecated && <span className="text-[9px] text-semantic-warning">deprecated</span>}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const r = (e.target as HTMLElement).getBoundingClientRect()
|
||||
onMenu(r.left, r.bottom)
|
||||
}}
|
||||
className="ml-auto text-content-muted hover:text-content-primary shrink-0"
|
||||
title="Actions"
|
||||
>
|
||||
<MoreVertical size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{param.description && (
|
||||
<div className="text-[10px] text-content-muted truncate ml-6" title={param.description}>
|
||||
{param.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-6 mt-0.5 flex items-center gap-1">
|
||||
{hasEnum && !freeText ? (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === OTHER_SENTINEL) {
|
||||
setFreeText(true)
|
||||
return
|
||||
}
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
className="flex-1 bg-surface-input border border-border rounded-sm px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{param.enum!.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
<option value={OTHER_SENTINEL}>Other…</option>
|
||||
</select>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.default}
|
||||
className="flex-1 bg-surface-input border border-border rounded-sm px-1.5 py-0.5 text-xs font-mono"
|
||||
/>
|
||||
{hasEnum && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFreeText(false)
|
||||
onChange(param.enum![0])
|
||||
}}
|
||||
className="text-[10px] text-accent-secondary hover:underline shrink-0"
|
||||
>
|
||||
list
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, ShieldOff } from 'lucide-react'
|
||||
import type { SJAuthScheme } from '../../lib/sjTypes'
|
||||
import type { SJProfile } from '../../stores/sjStore'
|
||||
|
||||
let profileSeq = 0
|
||||
/** A counter, not a timestamp: two profiles added inside the same millisecond
|
||||
* collided, and matrix columns are keyed by profile id, so React dropped one. */
|
||||
export function nextProfileID(): string {
|
||||
profileSeq += 1
|
||||
return `p${profileSeq}`
|
||||
}
|
||||
|
||||
export function makeProfile(index: number): SJProfile {
|
||||
return {
|
||||
id: nextProfileID(),
|
||||
label: index === 0 ? 'unauthenticated' : `profile ${index + 1}`,
|
||||
rank: index,
|
||||
enabled: true,
|
||||
credentials: [],
|
||||
headers: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** The auth profile manager.
|
||||
*
|
||||
* Its own modal rather than a block inside the Auth Matrix: a single send from
|
||||
* Operations and a bulk Automate run both authenticate too, so burying the
|
||||
* editor in the one view that happens to need several of them put it out of
|
||||
* reach of the two that need one. */
|
||||
export default function SJProfilesModal({
|
||||
profiles,
|
||||
schemes,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
profiles: SJProfile[]
|
||||
schemes: SJAuthScheme[]
|
||||
onChange: (next: SJProfile[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [editing, setEditing] = useState<string | null>(profiles.length === 1 ? profiles[0].id : null)
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [onClose])
|
||||
|
||||
function add() {
|
||||
const p = makeProfile(profiles.length)
|
||||
onChange([...profiles, p])
|
||||
setEditing(p.id)
|
||||
}
|
||||
|
||||
function move(idx: number, delta: number) {
|
||||
const next = profiles.slice()
|
||||
const j = idx + delta
|
||||
if (j < 0 || j >= next.length) return
|
||||
;[next[idx], next[j]] = [next[j], next[idx]]
|
||||
onChange(next.map((p, i) => ({ ...p, rank: i })))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-6"
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col w-full max-w-2xl max-h-[85vh] bg-surface-card border border-border rounded shadow-lg overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||
<span className="text-xs font-semibold text-content-primary uppercase tracking-wide">
|
||||
Auth profiles
|
||||
</span>
|
||||
<span className="text-[10px] text-content-muted">ordered least to most privileged</span>
|
||||
<button
|
||||
onClick={add}
|
||||
className="ml-auto flex items-center gap-1 px-3 py-1.5 rounded-sm bg-accent-secondary hover:bg-accent-secondary-hover text-black text-xs font-semibold"
|
||||
>
|
||||
<Plus size={13} /> Add profile
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-3 space-y-2">
|
||||
{profiles.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<ShieldOff size={22} className="text-content-muted" strokeWidth={1.5} />
|
||||
<div className="text-xs text-content-secondary">No auth profiles yet</div>
|
||||
<div className="text-[11px] text-content-muted max-w-md">
|
||||
A profile is one authentication state. Define one for each level of access you
|
||||
hold - plus one with no credentials - and the Auth Matrix will run every
|
||||
operation under each and tell you where a lower privilege reaches what a
|
||||
higher one does.
|
||||
</div>
|
||||
<div className="text-[11px] text-content-muted max-w-md">
|
||||
Credentials stay in memory for this session. They are never written to the
|
||||
project file and never returned by the API.
|
||||
</div>
|
||||
<button
|
||||
onClick={add}
|
||||
className="mt-1 flex items-center gap-1 px-3 py-1.5 rounded-sm bg-accent-secondary hover:bg-accent-secondary-hover text-black text-xs font-semibold"
|
||||
>
|
||||
<Plus size={13} /> Add a profile
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profiles.map((p, i) => (
|
||||
<div key={p.id} className="border border-border rounded-sm">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs">
|
||||
<span className="text-content-muted w-4 tabular-nums">{i}</span>
|
||||
<input
|
||||
value={p.label}
|
||||
onChange={(e) => {
|
||||
const next = profiles.slice()
|
||||
next[i] = { ...p, label: e.target.value }
|
||||
onChange(next)
|
||||
}}
|
||||
className="bg-surface-input border border-border rounded-sm px-2 py-1 w-44"
|
||||
/>
|
||||
<span className="text-[10px] text-content-muted">
|
||||
{(p.credentials?.length ?? 0) > 0
|
||||
? `${p.credentials!.length} credential${p.credentials!.length === 1 ? '' : 's'}`
|
||||
: 'no credentials'}
|
||||
{(p.headers?.length ?? 0) > 0 ? ` · ${p.headers!.length} header` : ''}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => move(i, -1)}
|
||||
className="text-content-muted hover:text-content-primary disabled:opacity-30"
|
||||
disabled={i === 0}
|
||||
title="Less privileged"
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(i, 1)}
|
||||
className="text-content-muted hover:text-content-primary disabled:opacity-30"
|
||||
disabled={i === profiles.length - 1}
|
||||
title="More privileged"
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(editing === p.id ? null : p.id)}
|
||||
className="px-2 text-xs text-accent-secondary hover:underline"
|
||||
>
|
||||
{editing === p.id ? 'Done' : 'Edit'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange(profiles.filter((x) => x.id !== p.id))}
|
||||
className="text-content-muted hover:text-semantic-error"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing === p.id && (
|
||||
<ProfileEditor
|
||||
profile={p}
|
||||
schemes={schemes}
|
||||
onChange={(next) => {
|
||||
const list = profiles.slice()
|
||||
list[i] = next
|
||||
onChange(list)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-t border-border">
|
||||
<span className="text-[10px] text-content-muted">
|
||||
Credentials are session-only and never reach the project file.
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto px-3 py-1.5 rounded-sm text-xs bg-surface-input hover:bg-surface-hover text-content-secondary"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileEditor({
|
||||
profile,
|
||||
schemes,
|
||||
onChange,
|
||||
}: {
|
||||
profile: SJProfile
|
||||
schemes: SJAuthScheme[]
|
||||
onChange: (next: SJProfile) => void
|
||||
}) {
|
||||
// Secret inputs carry joro-redact-field rather than a substituted value: the
|
||||
// real string has to stay in the DOM or the next keystroke corrupts it, so
|
||||
// streamer mode paints over it instead. redact.ts states the rule.
|
||||
function setCred(schemeName: string, patch: Record<string, string>) {
|
||||
const creds = (profile.credentials ?? []).slice()
|
||||
const idx = creds.findIndex((c) => c.scheme === schemeName)
|
||||
const scheme = schemes.find((s) => s.name === schemeName)
|
||||
if (!scheme) return
|
||||
const base = { scheme: schemeName, kind: scheme.kind, in: scheme.in, name: scheme.paramName }
|
||||
if (idx >= 0) creds[idx] = { ...creds[idx], ...patch }
|
||||
else creds.push({ ...base, ...patch })
|
||||
onChange({ ...profile, credentials: creds })
|
||||
}
|
||||
|
||||
const cred = (name: string) => (profile.credentials ?? []).find((c) => c.scheme === name)
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-surface-input/40 p-2 space-y-2 text-[11px]">
|
||||
{schemes.length === 0 && (
|
||||
<div className="text-content-muted">
|
||||
This document declares no security schemes. Use the extra headers below.
|
||||
</div>
|
||||
)}
|
||||
{schemes.map((s) => (
|
||||
<div key={s.name} className="space-y-1">
|
||||
<div className="text-content-secondary">
|
||||
{s.name}
|
||||
<span className="text-content-muted ml-1.5">
|
||||
{s.kind}
|
||||
{s.in ? ` · ${s.in} · ${s.paramName}` : ''}
|
||||
{!s.automatable ? ' · paste a token; Joro cannot obtain one' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{s.kind === 'basic' ? (
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
placeholder="username"
|
||||
value={cred(s.name)?.username ?? ''}
|
||||
onChange={(e) => setCred(s.name, { username: e.target.value })}
|
||||
className="flex-1 bg-surface-body border border-border rounded-sm px-2 py-1"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="password"
|
||||
value={cred(s.name)?.password ?? ''}
|
||||
onChange={(e) => setCred(s.name, { password: e.target.value })}
|
||||
className="flex-1 bg-surface-body border border-border rounded-sm px-2 py-1 joro-redact-field"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="password"
|
||||
placeholder={s.kind === 'bearer' ? 'token' : 'value'}
|
||||
value={cred(s.name)?.value ?? ''}
|
||||
onChange={(e) => setCred(s.name, { value: e.target.value })}
|
||||
className="w-full bg-surface-body border border-border rounded-sm px-2 py-1 joro-redact-field"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-1.5 border-t border-border">
|
||||
<div className="text-content-secondary mb-1">Extra headers</div>
|
||||
{(profile.headers ?? []).map((h, i) => (
|
||||
<div key={i} className="flex gap-1.5 mb-1">
|
||||
<input
|
||||
value={h.name}
|
||||
placeholder="Name"
|
||||
onChange={(e) => {
|
||||
const next = (profile.headers ?? []).slice()
|
||||
next[i] = { ...h, name: e.target.value }
|
||||
onChange({ ...profile, headers: next })
|
||||
}}
|
||||
className="w-40 bg-surface-body border border-border rounded-sm px-2 py-1"
|
||||
/>
|
||||
<input
|
||||
value={h.value}
|
||||
placeholder="Value"
|
||||
onChange={(e) => {
|
||||
const next = (profile.headers ?? []).slice()
|
||||
next[i] = { ...h, value: e.target.value }
|
||||
onChange({ ...profile, headers: next })
|
||||
}}
|
||||
className="flex-1 bg-surface-body border border-border rounded-sm px-2 py-1 joro-redact-field"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onChange({ ...profile, headers: (profile.headers ?? []).filter((_, j) => j !== i) })}
|
||||
className="text-content-muted hover:text-semantic-error"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onChange({ ...profile, headers: [...(profile.headers ?? []), { name: '', value: '' }] })}
|
||||
className="text-accent-secondary hover:underline"
|
||||
>
|
||||
+ header
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../../lib/api'
|
||||
import { b64DecodeUTF8 as b64Decode } from '../../lib/bytes'
|
||||
import TabButton from '../TabButton'
|
||||
import { ResponseRender, usePrettyJson } from '../ResponseRender'
|
||||
|
||||
/** Request and response for one run result.
|
||||
*
|
||||
* Bodies are fetched on selection rather than streamed with the row, the way
|
||||
* the fuzzer's detail pane works: a scan can hold thousands of responses, and
|
||||
* pushing every body over the WebSocket would cost far more than the operator
|
||||
* reads. */
|
||||
export default function SJResultDetail({ runId, index }: { runId: string; index: number }) {
|
||||
const [detail, setDetail] = useState<{ req: string; resp: string } | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [pane, setPane] = useState<'response' | 'request'>('response')
|
||||
const [respTab, setRespTab] = useState<'render' | 'raw'>('render')
|
||||
const [prettyJson, setPrettyJson] = usePrettyJson()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError('')
|
||||
api
|
||||
.sjGetResult(runId, index)
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setDetail({ req: b64Decode(res.reqRaw || ''), resp: b64Decode(res.respRaw || '') })
|
||||
})
|
||||
.catch((e) => !cancelled && setError(String((e as Error).message ?? e)))
|
||||
.finally(() => !cancelled && setLoading(false))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [runId, index])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-l border-border">
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border shrink-0">
|
||||
<TabButton active={pane === 'request'} onClick={() => setPane('request')}>
|
||||
Request
|
||||
</TabButton>
|
||||
<TabButton active={pane === 'response'} onClick={() => setPane('response')}>
|
||||
Response
|
||||
</TabButton>
|
||||
{pane === 'response' && (
|
||||
<>
|
||||
<span className="w-px h-3 bg-border mx-1" />
|
||||
<TabButton active={respTab === 'render'} onClick={() => setRespTab('render')}>
|
||||
Render
|
||||
</TabButton>
|
||||
<TabButton active={respTab === 'raw'} onClick={() => setRespTab('raw')}>
|
||||
Raw
|
||||
</TabButton>
|
||||
<label className="ml-auto flex items-center gap-1 text-[10px] text-content-secondary">
|
||||
<input type="checkbox" checked={prettyJson} onChange={(e) => setPrettyJson(e.target.checked)} />
|
||||
pretty
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 relative min-h-0 overflow-auto p-2">
|
||||
{loading ? (
|
||||
<div className="text-[11px] text-content-muted">Loading…</div>
|
||||
) : error ? (
|
||||
<div className="text-[11px] text-semantic-error">{error}</div>
|
||||
) : !detail ? null : pane === 'request' ? (
|
||||
<pre className="text-[10px] font-mono whitespace-pre-wrap break-all text-content-secondary">{detail.req}</pre>
|
||||
) : !detail.resp ? (
|
||||
<div className="text-[11px] text-content-muted">No response body was stored for this row.</div>
|
||||
) : respTab === 'render' ? (
|
||||
<ResponseRender raw={detail.resp} prettyJson={prettyJson} />
|
||||
) : (
|
||||
<pre className="text-[10px] font-mono whitespace-pre-wrap break-all text-content-secondary">{detail.resp}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Check, Square } from 'lucide-react'
|
||||
import type { SJOperation } from '../../lib/sjTypes'
|
||||
import { type SJScope, scopeCount } from '../../stores/sjStore'
|
||||
import { methodClass } from '../../lib/sjStatus'
|
||||
|
||||
/** The scope control for a run.
|
||||
*
|
||||
* A plain select rather than a dropdown component: the choices are few and
|
||||
* named, and the point is that the thing deciding what runs sits next to the
|
||||
* button that runs it. "Custom…" opens a picker, which is the only way this UI
|
||||
* offers to select in bulk — the checkbox-per-row it replaces had no select-all
|
||||
* at all. */
|
||||
export default function SJScopePicker({
|
||||
scope,
|
||||
ops,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
scope: SJScope
|
||||
ops: SJOperation[]
|
||||
onChange: (next: SJScope) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
|
||||
const tags = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const op of ops) {
|
||||
for (const t of op.tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
}, [ops])
|
||||
|
||||
const destructive = ops.filter((o) => o.destructive).length
|
||||
const value =
|
||||
scope.kind === 'tag' ? `tag:${scope.tag}` : scope.kind === 'custom' ? 'custom' : scope.kind
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-muted">
|
||||
Scope
|
||||
<select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (v === 'all') onChange({ kind: 'all' })
|
||||
else if (v === 'nonDestructive') onChange({ kind: 'nonDestructive' })
|
||||
else if (v.startsWith('tag:')) onChange({ kind: 'tag', tag: v.slice(4) })
|
||||
else setPicking(true)
|
||||
}}
|
||||
className="bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border disabled:opacity-50"
|
||||
>
|
||||
<option value="all">All operations ({ops.length})</option>
|
||||
{destructive > 0 && (
|
||||
<option value="nonDestructive">
|
||||
Non-destructive only ({ops.length - destructive})
|
||||
</option>
|
||||
)}
|
||||
{tags.map(([tag, n]) => (
|
||||
<option key={tag} value={`tag:${tag}`}>
|
||||
Tag: {tag} ({n})
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">
|
||||
{scope.kind === 'custom' ? `Custom (${scope.ids.length})…` : 'Custom…'}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{picking && (
|
||||
<OperationPicker
|
||||
ops={ops}
|
||||
initial={scope.kind === 'custom' ? scope.ids : []}
|
||||
onCancel={() => setPicking(false)}
|
||||
onConfirm={(ids) => {
|
||||
setPicking(false)
|
||||
onChange(ids.length > 0 ? { kind: 'custom', ids } : { kind: 'all' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function OperationPicker({
|
||||
ops,
|
||||
initial,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
ops: SJOperation[]
|
||||
initial: string[]
|
||||
onCancel: () => void
|
||||
onConfirm: (ids: string[]) => void
|
||||
}) {
|
||||
const [picked, setPicked] = useState<Record<string, true>>(
|
||||
Object.fromEntries(initial.map((id) => [id, true as const]))
|
||||
)
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const f = filter.toLowerCase()
|
||||
if (!f) return ops
|
||||
return ops.filter(
|
||||
(o) =>
|
||||
o.path.toLowerCase().includes(f) ||
|
||||
o.id.toLowerCase().includes(f) ||
|
||||
(o.summary ?? '').toLowerCase().includes(f)
|
||||
)
|
||||
}, [ops, filter])
|
||||
|
||||
const shownIds = shown.map((o) => o.id)
|
||||
const allShownPicked = shownIds.length > 0 && shownIds.every((id) => picked[id])
|
||||
const count = Object.keys(picked).length
|
||||
|
||||
function toggleAllShown() {
|
||||
const next = { ...picked }
|
||||
if (allShownPicked) for (const id of shownIds) delete next[id]
|
||||
else for (const id of shownIds) next[id] = true
|
||||
setPicked(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50"
|
||||
onMouseDown={onCancel}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col w-full max-w-2xl h-[70vh] bg-surface-card border border-border rounded shadow-lg overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<span className="text-xs font-semibold text-content-primary uppercase tracking-wide">
|
||||
Choose operations
|
||||
</span>
|
||||
<span className="text-[10px] text-content-muted">{count} selected</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<input
|
||||
autoFocus
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter…"
|
||||
className="flex-1 bg-surface-input text-xs px-2 py-1.5 rounded-sm border border-border"
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAllShown}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-sm text-xs bg-surface-input hover:bg-surface-hover text-content-secondary"
|
||||
>
|
||||
{allShownPicked ? <Square size={12} /> : <Check size={12} />}
|
||||
{allShownPicked ? 'None' : 'All'}
|
||||
{filter && ' shown'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{shown.map((o) => (
|
||||
<label
|
||||
key={o.id}
|
||||
className="flex items-center gap-2 px-3 py-1 text-xs cursor-pointer hover:bg-surface-hover"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!picked[o.id]}
|
||||
onChange={(e) => {
|
||||
const next = { ...picked }
|
||||
if (e.target.checked) next[o.id] = true
|
||||
else delete next[o.id]
|
||||
setPicked(next)
|
||||
}}
|
||||
/>
|
||||
<span className={`w-14 shrink-0 font-bold text-[10px] ${methodClass(o.method)}`}>
|
||||
{o.method}
|
||||
</span>
|
||||
<span className="font-mono truncate text-content-secondary">{o.path}</span>
|
||||
{o.destructive && (
|
||||
<span className="ml-auto text-[10px] text-semantic-warning shrink-0">destructive</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && (
|
||||
<div className="px-3 py-8 text-center text-xs text-content-muted">
|
||||
No operations match that filter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => setPicked({})}
|
||||
className="text-xs text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="ml-auto px-3 py-1.5 rounded-sm text-xs bg-surface-input hover:bg-surface-hover text-content-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(Object.keys(picked))}
|
||||
className="px-3 py-1.5 rounded-sm text-xs bg-accent-secondary hover:bg-accent-secondary-hover text-black font-semibold"
|
||||
>
|
||||
Use {count || 'all'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** scopeLabel describes a scope in a sentence, for a run's status line. */
|
||||
export function scopeLabel(scope: SJScope, ops: SJOperation[]): string {
|
||||
const n = scopeCount(scope, ops)
|
||||
switch (scope.kind) {
|
||||
case 'all':
|
||||
return `all ${n} operations`
|
||||
case 'nonDestructive':
|
||||
return `${n} non-destructive operations`
|
||||
case 'tag':
|
||||
return `${n} operations tagged ${scope.tag}`
|
||||
case 'custom':
|
||||
return `${n} selected operations`
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function WebhookDeliveries({ id }: { id: string }) {
|
||||
d.error ? 'text-semantic-error' : 'text-semantic-success'
|
||||
}`}
|
||||
>
|
||||
{d.status || '—'}
|
||||
{d.status || '-'}
|
||||
</td>
|
||||
<td className="py-0.5 pr-2 text-content-muted whitespace-nowrap">{d.durationMs}ms</td>
|
||||
<td className="py-0.5 pr-2 text-content-muted whitespace-nowrap">
|
||||
|
||||
@@ -245,7 +245,7 @@ export default function WebhookEditor({
|
||||
disabled={busy || creating || dirty}
|
||||
title={
|
||||
creating
|
||||
? 'Save this webhook first — a test sends a real request to the endpoint'
|
||||
? 'Save this webhook first - a test sends a real request to the endpoint'
|
||||
: dirty
|
||||
? 'Save first: a test sends what is stored, not what is on screen'
|
||||
: 'Send a sample event to this endpoint for real'
|
||||
@@ -294,7 +294,7 @@ export default function WebhookEditor({
|
||||
<Section title="Fires on">
|
||||
<p className="text-[10px] text-content-muted mb-1.5 leading-relaxed">
|
||||
Joro’s own events fire every time they happen; a custom trigger adds conditions
|
||||
so it fires on some of them. A trigger that is deleted or unreadable never fires —
|
||||
so it fires on some of them. A trigger that is deleted or unreadable never fires -
|
||||
it never means “no filter”.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -304,7 +304,7 @@ export default function WebhookEditor({
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => toggleTrigger(t.id)}
|
||||
title={t.builtin ? `Every ${t.on}` : `${t.on} — ${t.description || t.name}`}
|
||||
title={t.builtin ? `Every ${t.on}` : `${t.on} - ${t.description || t.name}`}
|
||||
className={`text-[10px] px-2 py-0.5 rounded-sm border ${
|
||||
on
|
||||
? 'border-accent-secondary bg-surface-input text-content-primary'
|
||||
@@ -364,7 +364,7 @@ export default function WebhookEditor({
|
||||
type="password"
|
||||
className={inputCls}
|
||||
value={h.auth.token ?? ''}
|
||||
placeholder={h.hasAuthSecret ? 'stored — leave blank to keep' : 'secret'}
|
||||
placeholder={h.hasAuthSecret ? 'stored - leave blank to keep' : 'secret'}
|
||||
onChange={(e) => patch({ auth: { ...h.auth, token: e.target.value } })}
|
||||
/>
|
||||
)}
|
||||
@@ -392,7 +392,7 @@ export default function WebhookEditor({
|
||||
type="password"
|
||||
className={inputCls}
|
||||
value={h.signing.secret ?? ''}
|
||||
placeholder={h.hasSigningSecret ? 'stored — leave blank to keep' : 'shared secret'}
|
||||
placeholder={h.hasSigningSecret ? 'stored - leave blank to keep' : 'shared secret'}
|
||||
onChange={(e) => patch({ signing: { ...h.signing, secret: e.target.value } })}
|
||||
/>
|
||||
</>
|
||||
@@ -428,7 +428,7 @@ export default function WebhookEditor({
|
||||
className={inputCls}
|
||||
value={hd.value}
|
||||
placeholder={
|
||||
h.secretHeaders.includes(hd.name) ? 'stored — leave blank to keep' : 'value'
|
||||
h.secretHeaders.includes(hd.name) ? 'stored - leave blank to keep' : 'value'
|
||||
}
|
||||
onChange={(e) => setHeader(i, { value: e.target.value })}
|
||||
/>
|
||||
@@ -504,7 +504,7 @@ export default function WebhookEditor({
|
||||
{h.format === 'slack' && 'Slack’s shape: {"text": "…"} carrying the one-line summary.'}
|
||||
{h.format === 'discord' && 'Discord’s shape: {"content": "…"} carrying the one-line summary.'}
|
||||
{h.format === 'template' &&
|
||||
'A JSON document you write. Placeholders are substituted into string values only, so what you write here is the finished shape — a value off the wire cannot add a key or break out of a string.'}
|
||||
'A JSON document you write. Placeholders are substituted into string values only, so what you write here is the finished shape - a value off the wire cannot add a key or break out of a string.'}
|
||||
</p>
|
||||
|
||||
{h.format === 'template' && (
|
||||
@@ -567,7 +567,7 @@ export default function WebhookEditor({
|
||||
</div>
|
||||
<p className="text-[10px] text-content-muted mt-1.5 leading-relaxed">
|
||||
Retries back off, doubling from half a second, and only for a timeout, a connection
|
||||
failure, a 429 or a 5xx — a 4xx is the receiver saying the request is wrong, and
|
||||
failure, a 429 or a 5xx - a 4xx is the receiver saying the request is wrong, and
|
||||
repeating it will not make it right. Events that arrive faster than the interval queue
|
||||
up; past the queue’s bound the oldest are dropped, and the count travels in the
|
||||
next delivery so the receiver knows it was told less than everything.
|
||||
@@ -598,7 +598,7 @@ export default function WebhookEditor({
|
||||
<p className="text-[10px] text-content-muted leading-relaxed">
|
||||
An automation calls <code className="font-mono">joro.webhook.send</code> with this
|
||||
webhook’s id and a one-line message. It cannot see or choose the destination, and
|
||||
the body stays the shape set above — the message fills{' '}
|
||||
the body stays the shape set above - the message fills{' '}
|
||||
<code className="font-mono">{'{{MESSAGE}}'}</code> and stands in for the summary. This
|
||||
tick is the whole gate: without it no automation can reach this endpoint, and none can
|
||||
tell it apart from one that does not exist.
|
||||
@@ -690,11 +690,11 @@ function TestOutcome({ test }: { test: WebhookTest }) {
|
||||
<div className="text-[11px]">
|
||||
{ok ? (
|
||||
<span className="text-semantic-success font-semibold">
|
||||
Delivered — {test.status} in {test.durationMs}ms
|
||||
Delivered - {test.status} in {test.durationMs}ms
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-semantic-error font-semibold">
|
||||
{test.status > 0 ? `${test.status} — ` : ''}
|
||||
{test.status > 0 ? `${test.status} - ` : ''}
|
||||
{test.error || 'no response'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function WebhookSettings() {
|
||||
? 'Disable'
|
||||
: h.paused
|
||||
? 'Enable (clears the pause)'
|
||||
: 'Enable — starts delivering on its triggers'
|
||||
: 'Enable - starts delivering on its triggers'
|
||||
}
|
||||
>
|
||||
{h.enabled ? <Power size={12} strokeWidth={2} /> : <PowerOff size={12} strokeWidth={2} />}
|
||||
@@ -215,7 +215,7 @@ export default function WebhookSettings() {
|
||||
Configuration lives in <code className="font-mono">~/.joro/webhooks.json</code>, which
|
||||
holds secrets and never travels inside a project config. Deliveries go straight out
|
||||
rather than through Joro’s own proxy, so they are never captured, scanned or
|
||||
rewritten — and a webhook watching traffic cannot feed itself.
|
||||
rewritten - and a webhook watching traffic cannot feed itself.
|
||||
</p>
|
||||
<p className="text-[10px] text-content-muted leading-relaxed max-w-xl mt-2">
|
||||
An automation can fire one by name, but only one you have ticked open to it. It
|
||||
|
||||
+23
-1
@@ -67,22 +67,40 @@ div:has(> .cm-editor) {
|
||||
background-color: var(--color-selection) !important;
|
||||
}
|
||||
|
||||
/* Drag handles for resizable panels */
|
||||
/* Drag handles for resizable panels.
|
||||
*
|
||||
* The divider stays 4px wide; ::after widens the grab area to 10px, because a
|
||||
* 4px target is a fussy thing to hit. z-index is required rather than cosmetic:
|
||||
* the pane after the handle in DOM order would otherwise paint over that
|
||||
* overhang, and at several sites it holds an `absolute inset-0` iframe.
|
||||
* position: relative is the containing block for the ::after, nothing more.
|
||||
*
|
||||
* The overhang grows away from the scrollbar, not symmetrically: a scrolling
|
||||
* pane's 6px scrollbar sits flush against the near side of its divider, and a
|
||||
* grab zone reaching back over it would take half the thumb with it. */
|
||||
.drag-handle-h {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: var(--color-border);
|
||||
flex-shrink: 0;
|
||||
touch-action: none;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
.drag-handle-h::after { content: ''; position: absolute; inset: 0 -6px 0 0; }
|
||||
.drag-handle-h:hover { background: var(--color-accent-secondary); }
|
||||
.drag-handle-v {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
background: var(--color-border);
|
||||
flex-shrink: 0;
|
||||
touch-action: none;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
.drag-handle-v::after { content: ''; position: absolute; inset: 0 0 -6px 0; }
|
||||
.drag-handle-v:hover { background: var(--color-accent-secondary); }
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -269,6 +287,10 @@ div:has(> .cm-editor) {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Chain map arcs: the binding gutter. Stroke and opacity both change on hover,
|
||||
so the transition is declared once here rather than per path. */
|
||||
.joro-chain-arc { transition: stroke 120ms ease, opacity 120ms ease; }
|
||||
|
||||
/* The delete control on a trigger connection.
|
||||
*
|
||||
* The edge label layer sets pointer-events: none so edges do not swallow clicks meant for
|
||||
|
||||
+145
-3
@@ -35,6 +35,38 @@ export interface ProjectMeta {
|
||||
active: boolean
|
||||
}
|
||||
import type { XSSProbe, XSSFire, PayloadVariant, CollectedPage, CollectedPageSummary, XSSConfig } from '../stores/xssHunterStore'
|
||||
import type {
|
||||
SJSpec, SJSpecSummary, SJRenderBody, SJSendResult, SJProfileSummary, SJProfileInput,
|
||||
SJScanBody, SJDiscoverBody, SJRunKind, SJRunSummary, SJRunDetail, SJMatrixView, SJResult,
|
||||
SJPlaceholders,
|
||||
} from './sjTypes'
|
||||
import type {
|
||||
Chain, ChainSummary, ChainProposal, ChainVariant, ChainPreviewStep, ChainPlan,
|
||||
ChainRecordCandidate, ChainRunSummary, ChainRunDetail, ChainGridView, ChainResult,
|
||||
ChainBinding, ChainEdit,
|
||||
ChainBindPreviewInput,
|
||||
ChainBindProposal,
|
||||
ChainStepResponse,
|
||||
} from './chainTypes'
|
||||
|
||||
// ChainInput is the write shape: raws travel base64 both ways, and a step's
|
||||
// id is assigned server-side on create.
|
||||
export interface ChainInput {
|
||||
name: string
|
||||
goalStepId?: string
|
||||
steps: {
|
||||
id?: string
|
||||
label?: string
|
||||
scheme: string
|
||||
host: string
|
||||
reqRaw: string
|
||||
respRaw?: string
|
||||
originSeq?: number
|
||||
setup?: boolean
|
||||
edits?: ChainEdit[]
|
||||
}[]
|
||||
bindings: ChainBinding[]
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
version: string
|
||||
@@ -1006,7 +1038,12 @@ const UI_ORIGIN_HEADER = { 'X-Joro-Origin': '1' } as const
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
readonly status: number,
|
||||
/** The decoded error body, when there was one. Most callers want only the
|
||||
* message; an endpoint that answers a refusal with structured advice — the
|
||||
* paths to try after a URL turned out to serve a UI page rather than a
|
||||
* document, say — needs the rest of it. */
|
||||
readonly body?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
@@ -1027,7 +1064,7 @@ async function req<T>(method: string, path: string, body?: unknown, timeoutMs?:
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new ApiError((err as { error: string }).error || res.statusText, res.status)
|
||||
throw new ApiError((err as { error: string }).error || res.statusText, res.status, err)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
} finally {
|
||||
@@ -1045,7 +1082,7 @@ async function upload<T>(path: string, form: FormData): Promise<T> {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new ApiError((err as { error: string }).error || res.statusText, res.status)
|
||||
throw new ApiError((err as { error: string }).error || res.statusText, res.status, err)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
@@ -1150,6 +1187,111 @@ export const api = {
|
||||
return upload<{ lines: string[]; count: number }>('/fuzzer/wordlist', form)
|
||||
},
|
||||
|
||||
// SJ — API description documents.
|
||||
// A document is loaded from exactly one source. `url` is fetched server-side
|
||||
// through Joro's own proxy, so the retrieval is captured into History like any
|
||||
// other traffic; the browser never fetches a target directly.
|
||||
sjLoad: (body: { raw?: string; text?: string; url?: string; name?: string }) =>
|
||||
req<{ spec: SJSpec }>('POST', '/spec/load', body),
|
||||
sjListSpecs: () => req<{ specs: SJSpecSummary[] }>('GET', '/spec/specs'),
|
||||
sjGetSpec: (id: string) => req<{ spec: SJSpec }>('GET', `/spec/specs/${id}`),
|
||||
sjGetSpecSource: (id: string) => req<{ source: string }>('GET', `/spec/specs/${id}/source`),
|
||||
sjDeleteSpec: (id: string) => req<unknown>('DELETE', `/spec/specs/${id}`),
|
||||
// Re-parses the stored document, so it returns a whole Spec: a placeholder is
|
||||
// baked into every generated default and body at parse time, not substituted
|
||||
// per request. The id is a hash of the source, so profiles survive.
|
||||
sjSetPlaceholders: (id: string, placeholders: Partial<SJPlaceholders>) =>
|
||||
req<{ spec: SJSpec }>('PUT', `/spec/specs/${id}/placeholders`, { placeholders }),
|
||||
|
||||
sjRender: (body: SJRenderBody) =>
|
||||
req<{ raw: string; scheme: string; host: string; path: string }>('POST', '/spec/render', body),
|
||||
// Send re-renders server-side unless `raw` is supplied, so a stale or
|
||||
// in-flight preview can never become the request that goes out.
|
||||
sjSend: (body: SJRenderBody & { raw?: string }) => req<SJSendResult>('POST', '/spec/send', body),
|
||||
|
||||
sjGetProfiles: (specId: string) =>
|
||||
req<{ profiles: SJProfileSummary[] }>('GET', `/spec/profiles?specId=${encodeURIComponent(specId)}`),
|
||||
sjSetProfiles: (specId: string, profiles: SJProfileInput[]) =>
|
||||
req<{ count: number }>('PUT', '/spec/profiles', { specId, profiles }),
|
||||
|
||||
sjScan: (body: SJScanBody) =>
|
||||
req<{ runId: string; kind: SJRunKind; total: number; warnings?: string[] }>('POST', '/spec/scan', body),
|
||||
sjDiscover: (body: SJDiscoverBody) =>
|
||||
req<{ runId: string; kind: SJRunKind; total: number; warnings?: string[] }>('POST', '/spec/discover', body),
|
||||
sjListRuns: () => req<{ runs: SJRunSummary[] }>('GET', '/spec/runs'),
|
||||
sjGetRun: (id: string, opts: { offset?: number; limit?: number } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.offset) qs.set('offset', String(opts.offset))
|
||||
if (opts.limit) qs.set('limit', String(opts.limit))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return req<SJRunDetail>('GET', `/spec/runs/${id}${suffix}`)
|
||||
},
|
||||
sjGetMatrix: (id: string) => req<SJMatrixView>('GET', `/spec/runs/${id}/matrix`),
|
||||
sjGetResult: (id: string, index: number) =>
|
||||
req<{ result: SJResult; reqRaw: string; respRaw: string }>('GET', `/spec/runs/${id}/results/${index}`),
|
||||
sjStopRun: (id: string) => req<unknown>('POST', `/spec/runs/${id}/stop`),
|
||||
sjDeleteRun: (id: string) => req<unknown>('DELETE', `/spec/runs/${id}`),
|
||||
|
||||
// Chain — multi-step workflow chains and the sweeps driven from them.
|
||||
chainList: () => req<{ chains: ChainSummary[] }>('GET', '/chain/chains'),
|
||||
chainGet: (id: string) => req<Chain>('GET', `/chain/chains/${id}`),
|
||||
chainCreate: (body: ChainInput) => req<Chain>('POST', '/chain/chains', body),
|
||||
chainUpdate: (id: string, body: ChainInput) =>
|
||||
req<Chain>('PUT', `/chain/chains/${id}`, body),
|
||||
chainDelete: (id: string) => req<unknown>('DELETE', `/chain/chains/${id}`),
|
||||
// Rows are addressed by seq, never by raw bytes: a selection survives
|
||||
// history.pushState's structured clone, and the server still has the capture.
|
||||
// With a chainId the steps are appended to that chain and correlation re-runs
|
||||
// over the extended chain; without one a new chain is created.
|
||||
chainFromHistory: (seqs: number[], name?: string, chainId?: string) =>
|
||||
req<Chain>('POST', '/chain/chains/from-history', { seqs, name, chainId }),
|
||||
// Correlation proposes and persists nothing; the operator accepts each one.
|
||||
chainCorrelate: (id: string) =>
|
||||
req<{ proposed: ChainProposal[] }>('POST', `/chain/chains/${id}/correlate`, {}),
|
||||
|
||||
// Proposes bindings for one hand-picked value, and persists nothing. The
|
||||
// inference is server-side so there is one definition of what a source can
|
||||
// read; see internal/chain/bind.go.
|
||||
chainBindPreview: (id: string, body: ChainBindPreviewInput) =>
|
||||
req<ChainBindProposal>('POST', `/chain/chains/${id}/bind-preview`, body),
|
||||
chainStepResponse: (id: string, stepId: string) =>
|
||||
req<ChainStepResponse>('GET', `/chain/chains/${id}/steps/${stepId}/response`),
|
||||
chainPreview: (id: string, variantId: string, kinds: string[]) =>
|
||||
req<{ variant: ChainVariant; steps: ChainPreviewStep[] }>(
|
||||
'POST', `/chain/chains/${id}/preview`, { variantId, kinds }),
|
||||
chainPlan: (id: string, kinds: string[]) =>
|
||||
req<ChainPlan>('GET', `/chain/chains/${id}/plan?kinds=${encodeURIComponent(kinds.join(','))}`),
|
||||
|
||||
chainRecordStart: () => req<{ cursor: number }>('POST', '/chain/record/start', {}),
|
||||
chainRecordStop: (cursor: number, opts: { host?: string; scopeOnly?: boolean } = {}) =>
|
||||
req<{ candidates: ChainRecordCandidate[]; cursor: number }>(
|
||||
'POST', '/chain/record/stop', { cursor, ...opts }),
|
||||
|
||||
chainStartRun: (body: {
|
||||
chainId: string
|
||||
kinds: string[]
|
||||
variantIds?: string[]
|
||||
allowStateChanging: boolean
|
||||
delayMs?: number
|
||||
timeoutMs?: number
|
||||
}) =>
|
||||
req<{ runId: string; total: number; variants: number; warnings: string[] }>(
|
||||
'POST', '/chain/runs', body),
|
||||
chainListRuns: () => req<{ runs: ChainRunSummary[] }>('GET', '/chain/runs'),
|
||||
chainGetRun: (id: string, opts: { offset?: number; limit?: number } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.offset) qs.set('offset', String(opts.offset))
|
||||
if (opts.limit) qs.set('limit', String(opts.limit))
|
||||
const suffix = qs.toString() ? `?${qs}` : ''
|
||||
return req<ChainRunDetail>('GET', `/chain/runs/${id}${suffix}`)
|
||||
},
|
||||
chainGetGrid: (id: string) => req<ChainGridView>('GET', `/chain/runs/${id}/grid`),
|
||||
chainGetResult: (id: string, index: number) =>
|
||||
req<{ result: ChainResult; reqRaw: string; respRaw: string }>(
|
||||
'GET', `/chain/runs/${id}/results/${index}`),
|
||||
chainStopRun: (id: string) => req<unknown>('POST', `/chain/runs/${id}/stop`),
|
||||
chainDeleteRun: (id: string) => req<unknown>('DELETE', `/chain/runs/${id}`),
|
||||
|
||||
// Generate
|
||||
generate: (format: string, mode?: string, implantUrl?: string, binaryName?: string, inMemory?: boolean) =>
|
||||
req<{ fileName: string; authKey: string; content: string }>(
|
||||
|
||||
@@ -2,6 +2,8 @@ import { api } from './api'
|
||||
import { useDetectStore } from '../stores/detectStore'
|
||||
import { useRequestStore } from '../stores/requestStore'
|
||||
import { useSettingsStore, type Settings } from '../stores/settingsStore'
|
||||
import { useSJStore } from '../stores/sjStore'
|
||||
import { useChainStore } from '../stores/chainStore'
|
||||
|
||||
// applyProjectResp propagates a project load/switch response to global live
|
||||
// state: it invalidates cached request history, refreshes settings into the
|
||||
@@ -14,6 +16,15 @@ export function applyProjectResp(_resp: unknown): void {
|
||||
// project's findings.
|
||||
useDetectStore.getState().clearAll()
|
||||
useDetectStore.getState().invalidate()
|
||||
// SJ holds the previous engagement's documents, targets and — crucially —
|
||||
// auth profiles, whose credentials are session state that must not follow the
|
||||
// operator into the next project.
|
||||
useSJStore.getState().clearAll()
|
||||
// Chains themselves travel in the project file and are refetched when the Chain
|
||||
// page mounts. Their runs do not: a run's rows reference History from the
|
||||
// previous engagement, and its verdicts were computed against a baseline
|
||||
// measured there.
|
||||
useChainStore.getState().clearAll()
|
||||
api
|
||||
.getSettings()
|
||||
.then((s) => useSettingsStore.getState().setSettings(s as Settings))
|
||||
|
||||
@@ -102,3 +102,29 @@ export function formatBytes(n: number): string {
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** b64DecodeUTF8 decodes base64 to a string, honoring UTF-8.
|
||||
*
|
||||
* Bare atob maps each byte to one code unit, so any multi-byte character — a
|
||||
* curly quote in a description, a non-Latin title — comes back mangled. The
|
||||
* SJ views each had their own copy of this; it lives here so there is one. */
|
||||
export function b64DecodeUTF8(s: string): string {
|
||||
try {
|
||||
return new TextDecoder().decode(b64ToBytes(s))
|
||||
} catch {
|
||||
try {
|
||||
return atob(s)
|
||||
} catch {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** b64EncodeUTF8 is its inverse. */
|
||||
export function b64EncodeUTF8(s: string): string {
|
||||
try {
|
||||
return bytesToB64(new TextEncoder().encode(s))
|
||||
} catch {
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Geometry for the Chain map: an ordered list of steps, plus the data
|
||||
// dependencies between them drawn as arcs in a gutter beside it.
|
||||
//
|
||||
// A list, not a canvas. Skip, repeat and reorder are list operations and a
|
||||
// Variant is a flat array of step ids, so free positioning would let the picture
|
||||
// lie about the execution order it exists to show. What is genuinely a graph is
|
||||
// the data, and the arcs get their own language so the two are not confused.
|
||||
//
|
||||
// validate.go refuses a binding whose producer does not run first, so every arc
|
||||
// points downward within one column — which makes routing interval packing over
|
||||
// a line rather than a layout engine.
|
||||
|
||||
import type { Chain, ChainBinding, ChainStep } from './chainTypes'
|
||||
|
||||
/** Where in a row an arc attaches, measured from the row's top. Line one's
|
||||
* centre, so an arc points at the method and label rather than at the URL. */
|
||||
export const PORT_DY = 15
|
||||
|
||||
/** Gutter lane pitch, and the gap between the innermost lane and the rows.
|
||||
*
|
||||
* LANE_PAD must stay larger than CORNER: the difference is the straight run the
|
||||
* arrowhead is drawn along, and closing it up renders the marker on its own
|
||||
* corner curve. */
|
||||
export const LANE_W = 11
|
||||
export const LANE_PAD = 13
|
||||
|
||||
/** Lanes past this share the outermost one. Overlapping arcs beyond five deep
|
||||
* are unreadable either way, and an unbounded gutter would eat the rows. */
|
||||
export const MAX_DEPTH = 5
|
||||
|
||||
/** Arc corner radius, clamped per arc so a short arc still rounds. */
|
||||
export const CORNER = 5
|
||||
|
||||
/** Arc is one binding placed in the gutter. */
|
||||
export interface Arc {
|
||||
id: string
|
||||
binding: ChainBinding
|
||||
/** Row indices, not ids: everything downstream is arithmetic on these. */
|
||||
from: number
|
||||
to: number
|
||||
/** 0 is the lane nearest the rows, so nested arcs hug what contains them. */
|
||||
depth: number
|
||||
/** The producer does not run before the consumer, so this is the state the
|
||||
* server will refuse to save. Drawn running upward, not merely recoloured. */
|
||||
backwards: boolean
|
||||
}
|
||||
|
||||
export interface ArcLayout {
|
||||
arcs: Arc[]
|
||||
/** Width the rows must be inset by to clear every lane. */
|
||||
gutterW: number
|
||||
}
|
||||
|
||||
/** stepIndex maps every step id to its position, once per layout. */
|
||||
function stepIndex(chain: Chain): Record<string, number> {
|
||||
const m: Record<string, number> = {}
|
||||
chain.steps.forEach((s, i) => (m[s.id] = i))
|
||||
return m
|
||||
}
|
||||
|
||||
/** layoutArcs assigns every binding a lane, so that no two arcs sharing a span
|
||||
* of rows share a lane.
|
||||
*
|
||||
* Greedy interval packing: sorted so an enclosing arc is placed before what it
|
||||
* contains, each takes the innermost free lane. That hands low lanes to the
|
||||
* enclosing arcs, which is visually backwards, so the number is inverted into
|
||||
* `depth` at the end — reversing the sort instead would break the packing. */
|
||||
export function layoutArcs(chain: Chain): ArcLayout {
|
||||
const idx = stepIndex(chain)
|
||||
const raw: Arc[] = []
|
||||
for (const b of chain.bindings) {
|
||||
const from = idx[b.fromStep]
|
||||
const to = idx[b.toStep]
|
||||
// A binding naming a step that is not in the chain has nothing to draw
|
||||
// between. Dropping it here keeps the picture honest; the save refuses it.
|
||||
if (from === undefined || to === undefined) continue
|
||||
raw.push({ id: b.id, binding: b, from, to, depth: 0, backwards: from >= to })
|
||||
}
|
||||
|
||||
const order = raw
|
||||
.map((a, i) => ({ a, i, lo: Math.min(a.from, a.to), hi: Math.max(a.from, a.to) }))
|
||||
.sort((x, y) => x.lo - y.lo || y.hi - x.hi || x.i - y.i)
|
||||
|
||||
const laneEnd: number[] = []
|
||||
const lane = new Map<string, number>()
|
||||
for (const { a, lo, hi } of order) {
|
||||
let L = laneEnd.findIndex((end) => end < lo)
|
||||
if (L < 0) L = laneEnd.length
|
||||
laneEnd[L] = hi
|
||||
lane.set(a.id, L)
|
||||
}
|
||||
|
||||
const maxLane = laneEnd.length ? laneEnd.length - 1 : 0
|
||||
for (const a of raw) {
|
||||
a.depth = Math.min(MAX_DEPTH, maxLane - (lane.get(a.id) ?? 0))
|
||||
}
|
||||
|
||||
const usedDepth = raw.length ? Math.max(...raw.map((a) => a.depth)) : -1
|
||||
const gutterW = usedDepth < 0 ? LANE_PAD : LANE_PAD + (usedDepth + 1) * LANE_W
|
||||
return { arcs: raw, gutterW }
|
||||
}
|
||||
|
||||
/** laneX is the horizontal centre of an arc's vertical run. */
|
||||
export function laneX(depth: number, gutterW: number): number {
|
||||
return Math.max(2, gutterW - LANE_PAD - depth * LANE_W)
|
||||
}
|
||||
|
||||
/** arcPath draws one arc as a rounded bracket: out of the producer row, down the
|
||||
* gutter, back into the consumer row.
|
||||
*
|
||||
* The final segment always runs in +x, so one marker with orient="auto" aims
|
||||
* every arrowhead with no per-arc rotation. A backwards arc genuinely runs
|
||||
* upward, so its direction survives without colour. */
|
||||
export function arcPath(x: number, y1: number, y2: number, gutterW: number): string {
|
||||
const dir = Math.sign(y2 - y1) || 1
|
||||
const r = Math.min(CORNER, Math.abs(y2 - y1) / 2)
|
||||
return (
|
||||
`M ${gutterW} ${y1} H ${x + r} Q ${x} ${y1} ${x} ${y1 + dir * r} ` +
|
||||
`V ${y2 - dir * r} Q ${x} ${y2} ${x + r} ${y2} H ${gutterW - 1}`
|
||||
)
|
||||
}
|
||||
|
||||
/** moveStep returns the chain with one step spliced to a new index. Shared by
|
||||
* the drag commit and the keyboard path so there is one definition of a move. */
|
||||
export function moveStep(chain: Chain, from: number, to: number): Chain {
|
||||
if (from === to || from < 0 || from >= chain.steps.length) return chain
|
||||
const steps = chain.steps.slice()
|
||||
const [moved] = steps.splice(from, 1)
|
||||
steps.splice(Math.max(0, Math.min(steps.length, to)), 0, moved)
|
||||
return { ...chain, steps }
|
||||
}
|
||||
|
||||
/** backwardsAfterMove names the bindings a candidate move would invert.
|
||||
*
|
||||
* Mirrors validate.go's producer-before-consumer rule and nothing else: a
|
||||
* client copy that grew to guess at the rest would refuse states the server
|
||||
* accepts. The server stays the authority. */
|
||||
export function backwardsAfterMove(chain: Chain, from: number, to: number): ChainBinding[] {
|
||||
return backwardsIn(moveStep(chain, from, to))
|
||||
}
|
||||
|
||||
/** backwardsIn names the bindings a chain currently holds that cannot resolve. */
|
||||
export function backwardsIn(chain: Chain): ChainBinding[] {
|
||||
const idx = stepIndex(chain)
|
||||
return chain.bindings.filter((b) => {
|
||||
const f = idx[b.fromStep]
|
||||
const t = idx[b.toStep]
|
||||
return f !== undefined && t !== undefined && f >= t
|
||||
})
|
||||
}
|
||||
|
||||
/** legalRange is the band of indices step `i` can move to with every one of its
|
||||
* bindings still resolvable: after the last step that produces a value it
|
||||
* consumes, and before the first step that consumes a value it produces.
|
||||
*
|
||||
* Shown as a shaded band during a drag, not enforced as a snap: a snap resists
|
||||
* silently, and blocks the intermediate states a multi-step rearrange passes
|
||||
* through. */
|
||||
export function legalRange(chain: Chain, i: number): [number, number] {
|
||||
const idx = stepIndex(chain)
|
||||
const step = chain.steps[i]
|
||||
if (!step) return [0, Math.max(0, chain.steps.length - 1)]
|
||||
let lo = 0
|
||||
let hi = chain.steps.length - 1
|
||||
for (const b of chain.bindings) {
|
||||
if (b.toStep === step.id) {
|
||||
const f = idx[b.fromStep]
|
||||
if (f !== undefined && f !== i) lo = Math.max(lo, f + 1)
|
||||
}
|
||||
if (b.fromStep === step.id) {
|
||||
const t = idx[b.toStep]
|
||||
if (t !== undefined && t !== i) hi = Math.min(hi, t - 1)
|
||||
}
|
||||
}
|
||||
return [lo, hi]
|
||||
}
|
||||
|
||||
/** requestTarget is the full URL a step sends to.
|
||||
*
|
||||
* Not step.label: chain.Label strips the query and truncates to 48 chars, and
|
||||
* a checkout and its confirm step can differ only in the query string.
|
||||
*
|
||||
* Only the head is decoded — 64 steps of up to a megabyte each is too much for
|
||||
* one line of text. atob yields latin-1, exact for a percent-encoded target. */
|
||||
export function requestTarget(step: ChainStep): string {
|
||||
let line = ''
|
||||
try {
|
||||
// A multiple of 4 so the slice is a whole number of base64 quanta.
|
||||
const head = atob(step.reqRaw.slice(0, 2048))
|
||||
const nl = head.indexOf('\n')
|
||||
line = nl < 0 ? head : head.slice(0, nl)
|
||||
} catch {
|
||||
return `${step.scheme}://${step.host}`
|
||||
}
|
||||
const parts = line.trim().split(' ')
|
||||
const target = parts.length > 1 ? parts[1] : ''
|
||||
if (!target || target.startsWith('http://') || target.startsWith('https://')) {
|
||||
return target || `${step.scheme}://${step.host}`
|
||||
}
|
||||
return `${step.scheme}://${step.host}${target}`
|
||||
}
|
||||
|
||||
/** stepTitle is a step's label without the method it already carries.
|
||||
*
|
||||
* chain.Label renders "METHOD /path" and the row badges the method itself, so
|
||||
* without this every row reads "POST POST /checkout". */
|
||||
export function stepTitle(step: ChainStep): string {
|
||||
const m = step.method ?? ''
|
||||
return m && step.label.startsWith(m + ' ') ? step.label.slice(m.length + 1) : step.label
|
||||
}
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user