Add SJ and Chain tabs for offensive testing of API specification files and business logic flaw/workflow sequence testing

This commit is contained in:
Tony West committed 2026-09-22 19:46:01 -04:00
1 parent 043262089c
commit c3f97d331f
128 files changed
+18870 -219

No files matched your search

+1
View File
@@ -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
)
+4
View File
@@ -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=
+636
View File
@@ -0,0 +1,636 @@
package api
import (
"encoding/base64"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/chain"
"github.com/BishopFox/joro/internal/httptools"
"github.com/BishopFox/joro/internal/proxy"
)
// maxRecordCandidates bounds what a record window hands back for trimming. A
// recording left running over a browsing session would otherwise return the whole
// capture ring.
const maxRecordCandidates = 500
func (s *APIServer) handleChainListChains(w http.ResponseWriter, r *http.Request) {
chains := s.chainStore.List()
out := make([]map[string]any, 0, len(chains))
for _, c := range chains {
out = append(out, chainSummaryBody(c))
}
writeJSON(w, http.StatusOK, map[string]any{"chains": out})
}
// chainSummaryBody is the list projection: enough to render the picker without
// shipping every step's raw bytes, which are megabytes across a project.
func chainSummaryBody(c *chain.Chain) map[string]any {
hosts := map[string]bool{}
for _, st := range c.Steps {
hosts[st.Host] = true
}
list := make([]string, 0, len(hosts))
for h := range hosts {
list = append(list, h)
}
sort.Strings(list)
return map[string]any{
"id": c.ID, "name": c.Name,
"steps": len(c.Steps), "bindings": len(c.Bindings),
"hosts": list, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
}
}
func (s *APIServer) handleChainGetChain(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
writeJSON(w, http.StatusOK, chainBody(c))
}
// chainBody renders a chain with its raws base64-encoded. Raw bytes are not
// JSON-safe and a captured request routinely is not UTF-8.
func chainBody(c *chain.Chain) map[string]any {
steps := make([]map[string]any, 0, len(c.Steps))
for _, st := range c.Steps {
steps = append(steps, map[string]any{
"id": st.ID, "label": st.Label, "scheme": st.Scheme, "host": st.Host,
"reqRaw": base64.StdEncoding.EncodeToString(st.ReqRaw),
"respRaw": base64.StdEncoding.EncodeToString(st.RespRaw),
"originSeq": st.OriginSeq, "setup": st.Setup, "edits": st.Edits,
"method": chain.MethodOf(st.ReqRaw),
})
}
return map[string]any{
"id": c.ID, "name": c.Name, "goalStepId": c.GoalStepID,
"steps": steps, "bindings": c.Bindings,
"createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
}
}
// chainInput is the write shape. Raws arrive base64 for the same reason they
// leave that way.
type chainInput struct {
Name string `json:"name"`
GoalStepID string `json:"goalStepId"`
Steps []struct {
ID string `json:"id"`
Label string `json:"label"`
Scheme string `json:"scheme"`
Host string `json:"host"`
ReqRaw string `json:"reqRaw"`
RespRaw string `json:"respRaw"`
OriginSeq int `json:"originSeq"`
Setup bool `json:"setup"`
Edits []httptools.Edit `json:"edits"`
} `json:"steps"`
Bindings []chain.Binding `json:"bindings"`
}
func (in chainInput) toChain() (*chain.Chain, error) {
c := &chain.Chain{Name: in.Name, GoalStepID: in.GoalStepID, Bindings: in.Bindings}
for i, st := range in.Steps {
req, err := base64.StdEncoding.DecodeString(st.ReqRaw)
if err != nil {
return nil, fmt.Errorf("step %d: request is not valid base64", i+1)
}
resp, err := base64.StdEncoding.DecodeString(st.RespRaw)
if err != nil {
return nil, fmt.Errorf("step %d: response is not valid base64", i+1)
}
step := chain.Step{
ID: st.ID, Label: st.Label, Scheme: st.Scheme, Host: st.Host,
ReqRaw: req, RespRaw: resp, OriginSeq: st.OriginSeq, Setup: st.Setup,
Edits: st.Edits,
}
if step.Label == "" {
step.Label = chain.Label(req)
}
c.Steps = append(c.Steps, step)
}
return c, nil
}
func (s *APIServer) handleChainCreateChain(w http.ResponseWriter, r *http.Request) {
var in chainInput
if err := decodeJSONLimit(r, &in, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
c, err := in.toChain()
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
chain.Reindex(c)
saved, err := s.chainStore.Create(c)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, chainBody(saved))
}
func (s *APIServer) handleChainUpdateChain(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if s.chainStore.Get(id) == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
var in chainInput
if err := decodeJSONLimit(r, &in, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
c, err := in.toChain()
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
saved, err := s.chainStore.Update(id, c)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, chainBody(saved))
}
func (s *APIServer) handleChainDeleteChain(w http.ResponseWriter, r *http.Request) {
// A run holds a snapshot of the chain, so deleting one does not disturb a run
// in flight and there is nothing to refuse here.
if !s.chainStore.Delete(r.PathValue("id")) {
writeError(w, http.StatusNotFound, "no such chain")
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
// chainFromHistoryRequest builds a chain out of selected History rows, or
// appends them to one that already exists.
type chainFromHistoryRequest struct {
Name string `json:"name"`
Seqs []int `json:"seqs"`
// ChainID appends to an existing chain instead of creating one. History has
// one selected row at a time, so a chain is built a step at a time; appending
// server-side is what lets correlation re-run over the extended chain and
// wire the new step to values the earlier ones already produce.
ChainID string `json:"chainId,omitempty"`
}
// handleChainFromHistory is the primary way a chain comes into existence.
//
// Rows are addressed by Seq rather than by id: it is the handle the capture store
// is indexed on, and the only one a client can hold across a page reload without
// carrying raw bytes through history.pushState.
func (s *APIServer) handleChainFromHistory(w http.ResponseWriter, r *http.Request) {
var req chainFromHistoryRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if len(req.Seqs) == 0 {
writeError(w, http.StatusBadRequest, "select at least one request")
return
}
if len(req.Seqs) > chain.MaxSteps {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("%d requests exceeds the limit of %d steps", len(req.Seqs), chain.MaxSteps))
return
}
// Ascending, because a chain is an ordering and the order the rows were
// clicked in is not it.
seqs := append([]int(nil), req.Seqs...)
sort.Ints(seqs)
// A non-positive seq is a malformed request, not an evicted one, and the two
// need different words. Reporting a client that sent 0 or null as "no longer
// in the capture buffer" sends whoever is debugging it to look at eviction,
// which is exactly the wrong place — and is how a client sending undefined
// went unnoticed.
for _, seq := range seqs {
if seq <= 0 {
writeError(w, http.StatusBadRequest,
"no valid request sequence numbers were supplied")
return
}
}
steps := make([]chain.Step, 0, len(seqs))
var missing []int
for _, seq := range seqs {
item := s.store.GetBySeq(seq)
if item == nil {
missing = append(missing, seq)
continue
}
steps = append(steps, chainStepFromCapture(item))
}
if len(steps) == 0 {
writeError(w, http.StatusBadRequest, "none of those requests are still in the capture buffer")
return
}
var saved *chain.Chain
var err error
if req.ChainID != "" {
existing := s.chainStore.Get(req.ChainID)
if existing == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
existing.Steps = append(existing.Steps, steps...)
if len(existing.Steps) > chain.MaxSteps {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("that would make %d steps, over the limit of %d", len(existing.Steps), chain.MaxSteps))
return
}
// Reindex before correlating: correlation records byte spans against step
// ids, so it has to run after the ids are final.
chain.Reindex(existing)
for i := range existing.Steps {
if existing.Steps[i].Label == "" {
existing.Steps[i].Label = chain.Label(existing.Steps[i].ReqRaw)
}
}
existing.Bindings = mergeBindings(existing.Bindings, chain.Correlate(existing))
saved, err = s.chainStore.Update(existing.ID, existing)
} else {
name := strings.TrimSpace(req.Name)
if name == "" {
name = "chain"
}
c := chain.New(name, steps)
// Correlate on the way in. A chain that arrives already wired is the
// difference between a usable artifact and a list of requests the
// operator has to hand-thread a CSRF token through.
c.Bindings = chain.Correlate(c)
saved, err = s.chainStore.Create(c)
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
resp := chainBody(saved)
if len(missing) > 0 {
resp["warning"] = fmt.Sprintf("%d selected requests were no longer in the capture buffer", len(missing))
}
writeJSON(w, http.StatusCreated, resp)
}
func chainStepFromCapture(item *proxy.CapturedRequest) chain.Step {
scheme := "https"
if strings.HasPrefix(strings.ToLower(item.URL), "http://") {
scheme = "http"
}
return chain.Step{
Scheme: scheme,
Host: item.Host,
ReqRaw: append([]byte(nil), item.ReqRaw...),
RespRaw: append([]byte(nil), item.RespRaw...),
OriginSeq: item.Seq,
}
}
// handleChainRecordStart returns the capture watermark to record from.
//
// Recording reads the store forward from a cursor rather than hooking the proxy,
// which is the arrangement internal/detect's scanner already documents: no second
// path through the capture hot loop, and nothing to unwind if the operator
// navigates away without stopping.
func (s *APIServer) handleChainRecordStart(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"cursor": s.store.LastSeq()})
}
type chainRecordStopRequest struct {
Cursor int `json:"cursor"`
Host string `json:"host,omitempty"`
ScopeOnly bool `json:"scopeOnly,omitempty"`
}
// handleChainRecordStop returns the candidate rows captured since the cursor, for
// the operator to trim into steps. It creates nothing: a recording is a selection
// aid, and a chain built from unreviewed traffic is mostly analytics beacons.
func (s *APIServer) handleChainRecordStop(w http.ResponseWriter, r *http.Request) {
var req chainRecordStopRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
items := s.store.SinceSeq(req.Cursor, maxRecordCandidates)
host := strings.ToLower(strings.TrimSpace(req.Host))
out := make([]map[string]any, 0, len(items))
for _, it := range items {
if host != "" && !strings.Contains(strings.ToLower(it.Host), host) {
continue
}
if req.ScopeOnly && !s.scope.HostInScope(it.Host) {
continue
}
out = append(out, map[string]any{
"seq": it.Seq, "method": it.Method, "url": it.URL, "host": it.Host,
"status": it.StatusCode, "contentType": it.ContentType,
"size": it.ResponseSize, "timestamp": it.Timestamp,
"label": chain.Label(it.ReqRaw),
})
}
// Ascending: SinceSeq walks backwards from newest, and a recording reads
// forwards.
sort.Slice(out, func(i, j int) bool { return out[i]["seq"].(int) < out[j]["seq"].(int) })
writeJSON(w, http.StatusOK, map[string]any{"candidates": out, "cursor": s.store.LastSeq()})
}
// handleChainCorrelate proposes bindings and persists nothing.
//
// A proposal, like a render is a preview: correlation is a heuristic over
// entropy and literal occurrence, and writing its guesses straight into the chain
// would put the operator in the position of un-picking them.
func (s *APIServer) handleChainCorrelate(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
// Re-keyed before they leave: the client appends accepted proposals straight
// into the open chain.
proposed := assignBindingIDs(c.Bindings, chain.Correlate(c))
// Report which proposals are new, so the UI can offer them without
// discarding bindings the operator has already accepted or hand-written.
existing := map[string]bool{}
for _, b := range c.Bindings {
existing[bindingKey(b)] = true
}
out := make([]map[string]any, 0, len(proposed))
for _, b := range proposed {
out = append(out, map[string]any{"binding": b, "isNew": !existing[bindingKey(b)]})
}
writeJSON(w, http.StatusOK, map[string]any{"proposed": out})
}
// mergeBindings keeps every existing binding and adds only proposals that are
// not already present.
//
// Additive rather than replacing, because a re-correlation must not discard a
// binding the operator hand-wrote, renamed, or set to send its recorded value.
func mergeBindings(existing, proposed []chain.Binding) []chain.Binding {
have := make(map[string]bool, len(existing))
for _, b := range existing {
have[bindingKey(b)] = true
}
fresh := make([]chain.Binding, 0, len(proposed))
for _, b := range proposed {
if have[bindingKey(b)] {
continue
}
fresh = append(fresh, b)
have[bindingKey(b)] = true
}
return append(append([]chain.Binding(nil), existing...), assignBindingIDs(existing, fresh)...)
}
// assignBindingIDs re-keys proposals against the ids a chain already holds.
//
// Correlate numbers from one every run, and a count of the existing bindings is
// not the next free id once one has been deleted — so the collision surfaces as
// a refused save, several clicks from the accept that caused it.
func assignBindingIDs(existing, proposed []chain.Binding) []chain.Binding {
taken := make(map[string]bool, len(existing))
for _, b := range existing {
taken[b.ID] = true
}
next := 1
out := make([]chain.Binding, 0, len(proposed))
for _, b := range proposed {
for {
id := "b" + strconv.Itoa(next)
next++
if !taken[id] {
b.ID = id
taken[id] = true
break
}
}
out = append(out, b)
}
return out
}
// bindingKey identifies a binding by what it does rather than by its id, so a
// re-run of correlation recognizes a proposal the operator already accepted.
func bindingKey(b chain.Binding) string {
var sb strings.Builder
sb.WriteString(b.FromStep)
sb.WriteString(">")
sb.WriteString(b.ToStep)
sb.WriteString(">")
sb.WriteString(b.Source.Kind)
sb.WriteString(":")
sb.WriteString(b.Source.Name)
sb.WriteString(b.Source.Path)
for _, sp := range b.Spans {
fmt.Fprintf(&sb, "@%d-%d", sp.Start, sp.End)
}
return sb.String()
}
// chainPreviewRequest renders a variant without sending it.
type chainPreviewRequest struct {
VariantID string `json:"variantId"`
Kinds []string `json:"kinds,omitempty"`
}
// handleChainPreview renders every step of one variant and never sends.
//
// The cheap way to confirm substitution is right before arming a sweep that
// creates real orders. Values a previous step would have produced are unknown
// here, so a step that consumes one renders as unresolved — which is exactly what
// the run would do, and is the point: the preview shows where an ordering breaks
// before it costs anything.
func (s *APIServer) handleChainPreview(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
var req chainPreviewRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
variants := chain.GenerateVariants(c, req.Kinds)
var chosen *chain.Variant
for i := range variants {
if variants[i].ID == req.VariantID || req.VariantID == "" {
chosen = &variants[i]
break
}
}
if chosen == nil {
writeError(w, http.StatusNotFound, "no such variant")
return
}
// Seed the table with each binding's recorded value so the preview shows the
// substitution actually happening. A run resolves these from live responses;
// here there are none, and rendering every step unresolved would show the
// operator nothing about whether the spans are right.
vars := chain.Vars{}
for _, b := range c.Bindings {
vars[b.Var] = b.Recorded
}
steps := make([]map[string]any, 0, len(chosen.Steps))
for _, id := range chosen.Steps {
st, _, ok := c.StepByID(id)
if !ok {
continue
}
raw, missing, err := chain.Render(c, st, vars)
entry := map[string]any{"stepId": id, "label": st.Label}
switch {
case err != nil:
entry["error"] = err.Error()
case len(missing) > 0:
entry["missing"] = missing
default:
entry["raw"] = base64.StdEncoding.EncodeToString(raw)
}
steps = append(steps, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"variant": chosen, "steps": steps})
}
// chainBindPreviewRequest is one operator-chosen value offered for binding.
//
// The value, not an offset: the pane shows the decoded response, whose offsets
// mean nothing against the recorded bytes. Base64 because a run of bytes out of
// a response is not reliably UTF-8.
type chainBindPreviewRequest struct {
FromStep string `json:"fromStep"`
Value string `json:"value"`
Occurrence int `json:"occurrence,omitempty"`
Source *chain.Source `json:"source,omitempty"`
ToSteps []string `json:"toSteps,omitempty"`
Spans map[string][]chain.Span `json:"spans,omitempty"`
}
// handleChainBindPreview proposes bindings for one value and persists nothing.
//
// A proposal, like correlation: nothing here is confirmed against a live
// response. It reuses correlation's envelope so the client has one accept path.
func (s *APIServer) handleChainBindPreview(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
var req chainBindPreviewRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
value, err := base64.StdEncoding.DecodeString(req.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "value is not base64")
return
}
p, err := chain.ProposeBinding(c, req.FromStep, string(value), chain.BindOptions{
Occurrence: req.Occurrence,
Source: req.Source,
ToSteps: req.ToSteps,
Spans: req.Spans,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
fresh := make([]chain.Binding, 0, len(p.Targets))
for _, t := range p.Targets {
fresh = append(fresh, t.Binding)
}
keyed := assignBindingIDs(c.Bindings, fresh)
existing := map[string]bool{}
for _, b := range c.Bindings {
existing[bindingKey(b)] = true
}
out := make([]map[string]any, 0, len(keyed))
for i, b := range keyed {
out = append(out, map[string]any{
"binding": b,
"isNew": !existing[bindingKey(b)],
"conflict": p.Targets[i].Conflict,
"note": p.Targets[i].Note,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"var": p.Var,
"recorded": base64.StdEncoding.EncodeToString([]byte(p.Recorded)),
"sources": p.Sources,
"proposed": out,
"warnings": p.Warnings,
})
}
// handleChainStepResponse returns a step's recorded response, decoded.
//
// Decoded here, not in the client: ReadResponse is also what the sources this
// pane produces are evaluated against, so a TypeScript decoder would be a second
// answer to what the step returned. On demand, because chainBody already ships
// every step's raws and a decoded copy would double every chain GET.
func (s *APIServer) handleChainStepResponse(w http.ResponseWriter, r *http.Request) {
c := s.chainStore.Get(r.PathValue("id"))
if c == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
st, _, ok := c.StepByID(r.PathValue("stepId"))
if !ok {
writeError(w, http.StatusNotFound, "no such step")
return
}
resp := httptools.ReadResponse(st.RespRaw)
writeJSON(w, http.StatusOK, map[string]any{
"status": resp.Status,
"headers": base64.StdEncoding.EncodeToString(chainHeaderBytes(resp)),
"body": base64.StdEncoding.EncodeToString(resp.Body),
// Verbatim from the parser, so a body this could not decode says which
// encoding defeated it rather than rendering as broken text.
"decoded": resp.Decoded,
"bodyLen": len(resp.Body),
})
}
// chainHeaderBytes re-serializes a parsed response's status line and headers.
// A regex or between source resolves against headers too, so they have to be
// selectable in the pane.
func chainHeaderBytes(resp httptools.Response) []byte {
var b strings.Builder
fmt.Fprintf(&b, "HTTP/1.1 %d\r\n", resp.Status)
names := make([]string, 0, len(resp.Header))
for name := range resp.Header {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
for _, v := range resp.Header.Values(name) {
fmt.Fprintf(&b, "%s: %s\r\n", name, v)
}
}
return []byte(b.String())
}
+281
View File
@@ -0,0 +1,281 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/chain"
"github.com/BishopFox/joro/internal/chainrun"
"github.com/BishopFox/joro/internal/proxy"
)
// chainRunRequest starts a sweep. A single-variant run is a sweep of one, which
// is why there is one endpoint and one runner rather than two of each.
type chainRunRequest struct {
ChainID string `json:"chainId"`
Kinds []string `json:"kinds"`
// VariantIDs narrows a sweep to specific variants, for re-running one row of
// a grid without re-running the whole thing. The baseline is always included
// regardless: it is the comparison basis, and a run without it has nothing
// to compare against.
VariantIDs []string `json:"variantIds,omitempty"`
AllowStateChanging bool `json:"allowStateChanging"`
DelayMs int `json:"delayMs"`
TimeoutMs int `json:"timeoutMs"`
BudgetMs int `json:"budgetMs"`
}
func (s *APIServer) handleChainRunStart(w http.ResponseWriter, r *http.Request) {
var req chainRunRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
ch := s.chainStore.Get(req.ChainID)
if ch == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
variants := chain.GenerateVariants(ch, req.Kinds)
if len(req.VariantIDs) > 0 {
variants = filterVariants(variants, req.VariantIDs)
}
cfg := chainrun.Config{
ChainID: ch.ID,
Chain: ch,
Variants: variants,
AllowStateChanging: req.AllowStateChanging,
DelayMs: req.DelayMs,
TimeoutMs: req.TimeoutMs,
BudgetMs: req.BudgetMs,
}
total, err := chainrun.Plan(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// One at a time, globally. Two sweeps against one application interleave on
// its own state and both produce verdicts describing the interleaving. This
// refuses rather than queueing, because a queue would hide the constraint
// behind a wait and the operator would never learn why it exists. The claim
// and the insert are one operation, so two requests cannot both pass.
run := chainrun.NewRun(proxy.GenerateID(), cfg, total)
if running := s.chainRuns.AddIfIdle(run); running != nil {
writeError(w, http.StatusConflict,
fmt.Sprintf("a run of %q is already in flight; stop it before starting another", running.ChainName))
return
}
deps := chainrun.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
// context.Background, not the request's: a sweep outlives the HTTP call that
// started it.
go chainrun.Execute(context.Background(), run, deps)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "total": total, "variants": len(variants),
"warnings": s.chainRunWarnings(ch, variants, total),
})
}
// filterVariants narrows a generated set, always keeping the baseline.
func filterVariants(all []chain.Variant, ids []string) []chain.Variant {
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
out := make([]chain.Variant, 0, len(ids)+1)
for _, v := range all {
if v.Kind == chain.VariantBaseline || want[v.ID] {
out = append(out, v)
}
}
return out
}
// chainRunWarnings reports what a sweep is about to do, before it does it.
//
// The request count is the number an operator is surprised by: a seven-step
// checkout with three mutation kinds is not seven requests, it is most of a
// hundred, and a dozen of them are real POSTs to a payment endpoint.
func (s *APIServer) chainRunWarnings(ch *chain.Chain, variants []chain.Variant, total int) []string {
out := []string{}
if methods := chain.StateChangingMethods(ch); len(methods) > 0 {
var targets []string
seen := map[string]bool{}
for _, st := range ch.Steps {
m := chain.MethodOf(st.ReqRaw)
if m == "" || m == "GET" || m == "HEAD" || m == "OPTIONS" {
continue
}
key := m + " " + chain.TargetOf(st.ReqRaw)
if !seen[key] {
seen[key] = true
targets = append(targets, st.Label)
}
}
if len(targets) > 3 {
targets = append(targets[:3], fmt.Sprintf("and %d more", len(targets)-3))
}
out = append(out, fmt.Sprintf(
"%d variants will send %d requests, including %s to %s — each variant repeats these for real",
len(variants), total, strings.Join(methods, "/"), strings.Join(targets, ", ")))
} else {
out = append(out, fmt.Sprintf("%d variants will send %d requests", len(variants), total))
}
if s.settings.InterceptEnabled {
out = append(out, fmt.Sprintf(
"request intercept is armed, so all %d requests will pause in the Intercept queue", total))
}
if total > 500 {
out = append(out, fmt.Sprintf(
"%d requests will be captured into History, which may evict older traffic from the capture buffer", total))
}
return out
}
// handleChainPlan reports what a sweep would do without starting it, so the
// warnings and the request count can be shown beside the arming checkbox rather
// than only after the operator has committed.
func (s *APIServer) handleChainPlan(w http.ResponseWriter, r *http.Request) {
ch := s.chainStore.Get(r.PathValue("id"))
if ch == nil {
writeError(w, http.StatusNotFound, "no such chain")
return
}
kinds := strings.Split(r.URL.Query().Get("kinds"), ",")
variants := chain.GenerateVariants(ch, kinds)
total, err := chain.Plan(ch, variants)
resp := map[string]any{
"variants": variants,
"total": total,
"warnings": s.chainRunWarnings(ch, variants, total),
"methods": chain.StateChangingMethods(ch),
}
if err != nil {
resp["error"] = err.Error()
}
writeJSON(w, http.StatusOK, resp)
}
func (s *APIServer) handleChainListRuns(w http.ResponseWriter, r *http.Request) {
runs := s.chainRuns.List()
out := make([]map[string]any, 0, len(runs))
for _, run := range runs {
out = append(out, chainRunHead(run))
}
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
}
func chainRunHead(run *chainrun.Run) map[string]any {
completed, errs, unresolved, bypassed := run.Counts()
return map[string]any{
"id": run.ID, "chainId": run.ChainID, "chainName": run.ChainName,
"status": string(run.Status()), "total": run.Total, "stride": run.Stride,
"completed": completed, "errors": errs, "unresolved": unresolved, "bypassed": bypassed,
"createdAt": run.CreatedAt,
}
}
func (s *APIServer) handleChainGetRun(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
results := run.Results()
offset, limit := pageParams(r, len(results))
// Materialized, never nil: a nil slice marshals as null against a client
// type that says array. See chain.Chain.Normalize.
page := []chainrun.Result{}
if offset < len(results) {
end := min(offset+limit, len(results))
page = results[offset:end]
}
body := chainRunHead(run)
body["steps"] = run.Steps
body["variants"] = run.Variants
body["verdicts"] = run.Verdicts()
body["results"] = page
body["resultTotal"] = len(results)
body["offset"] = offset
body["limit"] = limit
writeJSON(w, http.StatusOK, body)
}
func (s *APIServer) handleChainGetGrid(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
writeJSON(w, http.StatusOK, chainrun.Grid(run))
}
// handleChainGetResult returns one instance including its raw bytes, fetched on
// demand rather than streamed, as the fuzzer's and sj's detail endpoints are.
func (s *APIServer) handleChainGetResult(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
writeError(w, http.StatusBadRequest, "index must be a number")
return
}
res, ok := run.ResultAt(idx)
if !ok {
writeError(w, http.StatusNotFound, "no such result")
return
}
reqRaw, respRaw := res.ReqRaw, res.RespRaw
res.ReqRaw, res.RespRaw = nil, nil
writeJSON(w, http.StatusOK, map[string]any{
"result": res,
"reqRaw": base64.StdEncoding.EncodeToString(reqRaw),
"respRaw": base64.StdEncoding.EncodeToString(respRaw),
})
}
func (s *APIServer) handleChainStopRun(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if !run.Stop() {
writeError(w, http.StatusConflict, "run is not running")
return
}
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
}
func (s *APIServer) handleChainDeleteRun(w http.ResponseWriter, r *http.Request) {
run := s.chainRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Status() == chainrun.StatusRunning {
writeError(w, http.StatusConflict, "stop the run before deleting it")
return
}
s.chainRuns.Delete(run.ID)
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
+69 -6
View File
@@ -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
+8 -1
View File
@@ -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,
+626
View File
@@ -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
}
+411
View File
@@ -0,0 +1,411 @@
package api
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/BishopFox/joro/internal/apiscan"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/proxy"
)
// specScanRequest starts a scan or an auth matrix. The two differ only in how
// many profiles are named — a scan is a matrix with one.
type specScanRequest struct {
SpecID string `json:"specId"`
OperationIDs []string `json:"operationIds"`
ProfileIDs []string `json:"profileIds"`
ServerIndex *int `json:"serverIndex,omitempty"`
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"`
BasePath *string `json:"basePath,omitempty"`
Values map[string]map[string]string `json:"values,omitempty"` // opID -> values
Concurrency int `json:"concurrency"`
RatePerSec float64 `json:"ratePerSec"`
TimeoutMs int `json:"timeoutMs"`
BudgetMs int `json:"budgetMs"`
UserAgent string `json:"userAgent"`
AllowDestructive bool `json:"allowDestructive"`
Methods []string `json:"methods,omitempty"`
}
func (s *APIServer) handleSpecScanStart(w http.ResponseWriter, r *http.Request) {
var req specScanRequest
if err := decodeJSONLimit(r, &req, maxBulkJSONBody); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
stored := s.specStore.Get(req.SpecID)
if stored == nil {
writeError(w, http.StatusNotFound, "no such document")
return
}
ops := selectOperations(stored.Spec, req.OperationIDs)
if len(ops) == 0 {
writeError(w, http.StatusBadRequest, "no operations selected")
return
}
profiles, err := s.selectProfiles(req.SpecID, req.ProfileIDs)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
srv := serverFor(stored.Spec, req.ServerIndex, req.Scheme, req.Host, req.BasePath)
if srv.Host == "" {
writeError(w, http.StatusBadRequest,
"no target host: the document declares no server, so scheme and host must be supplied")
return
}
kind := apiscan.KindScan
if len(profiles) > 1 {
kind = apiscan.KindMatrix
}
values := map[string]apispec.Values{}
for opID, v := range req.Values {
values[opID] = apispec.Values(v)
}
cfg := apiscan.Config{
Kind: kind, SpecID: req.SpecID, Server: srv, Ops: ops, Profiles: profiles, Values: values,
Concurrency: req.Concurrency, RatePerSec: req.RatePerSec,
TimeoutMs: req.TimeoutMs, BudgetMs: req.BudgetMs, UserAgent: req.UserAgent,
AllowDestructive: req.AllowDestructive, Methods: normalizeMethods(req.Methods),
}
total, err := apiscan.Plan(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
run := apiscan.NewRun(proxy.GenerateID(), cfg, total)
s.specRuns.Add(run)
deps := apiscan.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
go apiscan.Execute(context.Background(), run, deps)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "kind": string(kind), "total": total,
"warnings": s.runWarnings(total),
})
}
// specDiscoverRequest starts a document sweep against a host.
type specDiscoverRequest struct {
Scheme string `json:"scheme"`
Host string `json:"host"`
BasePath string `json:"basePath"`
Full bool `json:"full"`
StopOnFirst bool `json:"stopOnFirst"`
Concurrency int `json:"concurrency"`
RatePerSec float64 `json:"ratePerSec"`
TimeoutMs int `json:"timeoutMs"`
BudgetMs int `json:"budgetMs"`
UserAgent string `json:"userAgent"`
}
func (s *APIServer) handleSpecDiscoverStart(w http.ResponseWriter, r *http.Request) {
var req specDiscoverRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
set := apispec.CandidatePriority
if req.Full {
set = apispec.CandidateFull
}
cfg := apiscan.Config{
Kind: apiscan.KindDiscovery, Scheme: strings.ToLower(req.Scheme), Host: req.Host,
BasePath: req.BasePath, CandidateSet: set, StopOnFirst: req.StopOnFirst,
Concurrency: req.Concurrency, RatePerSec: req.RatePerSec,
TimeoutMs: req.TimeoutMs, BudgetMs: req.BudgetMs, UserAgent: req.UserAgent,
}
if cfg.Scheme == "" {
cfg.Scheme = "https"
}
paths, err := apiscan.PlanDiscovery(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
run := apiscan.NewRun(proxy.GenerateID(), cfg, len(paths))
s.specRuns.Add(run)
deps := apiscan.Deps{Send: s.specSendDeps(), Broadcast: s.hub.Broadcast()}
// A document found mid-sweep is loaded straight into the store, so the
// operator can switch to it without a second fetch.
//
// Only when it is not already held. A sweep parses with default placeholders,
// so re-finding bytes the operator has since set a placeholder on would
// silently revert their parse under the same ID. If it is already stored,
// switching to it already works, which is all this Put was for.
onSpec := func(spec *apispec.Spec, source []byte) {
if s.specStore.Get(spec.ID) == nil {
s.specStore.Put(spec, source)
}
s.hub.Broadcast() <- event.WSEvent{Type: "spec.run.found", Data: map[string]any{
"runId": run.ID, "specId": spec.ID, "title": spec.Title,
"format": string(spec.Format), "operations": len(spec.Operations),
"sourceUrl": spec.SourceURL,
}}
}
go apiscan.Discover(context.Background(), run, paths, deps, onSpec)
writeJSON(w, http.StatusCreated, map[string]any{
"runId": run.ID, "kind": string(apiscan.KindDiscovery), "total": len(paths),
"warnings": s.runWarnings(len(paths)),
})
}
func (s *APIServer) handleSpecListRuns(w http.ResponseWriter, r *http.Request) {
runs := s.specRuns.List()
out := make([]map[string]any, 0, len(runs))
for _, run := range runs {
completed, errs, skipped, hits := run.Counts()
out = append(out, map[string]any{
"id": run.ID, "kind": string(run.Kind), "specId": run.SpecID,
"status": string(run.Status()), "total": run.Total,
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
"createdAt": run.CreatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{"runs": out})
}
func (s *APIServer) handleSpecGetRun(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
results := run.Results()
offset, limit := pageParams(r, len(results))
// Materialized, never nil: a nil slice marshals as null against a client type
// that says array. See apispec.Spec.normalize.
page := []apiscan.Result{}
if offset < len(results) {
end := min(offset+limit, len(results))
page = results[offset:end]
}
completed, errs, skipped, hits := run.Counts()
writeJSON(w, http.StatusOK, map[string]any{
"id": run.ID, "kind": string(run.Kind), "specId": run.SpecID,
"status": string(run.Status()), "total": run.Total,
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
"createdAt": run.CreatedAt,
"results": page, "resultTotal": len(results), "offset": offset, "limit": limit,
})
}
func (s *APIServer) handleSpecGetMatrix(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Kind == apiscan.KindDiscovery {
writeError(w, http.StatusBadRequest, "a discovery run has no auth matrix")
return
}
writeJSON(w, http.StatusOK, apiscan.Matrix(run))
}
// handleSpecGetResult returns one result including its raw bytes, fetched on
// demand rather than streamed, as the fuzzer's detail endpoint is.
func (s *APIServer) handleSpecGetResult(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
idx, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
writeError(w, http.StatusBadRequest, "index must be a number")
return
}
res, ok := run.ResultAt(idx)
if !ok {
writeError(w, http.StatusNotFound, "no such result")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"result": stripRaw(res),
"reqRaw": base64.StdEncoding.EncodeToString(res.ReqRaw),
"respRaw": base64.StdEncoding.EncodeToString(res.RespRaw),
})
}
func (s *APIServer) handleSpecStopRun(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if !run.Stop() {
writeError(w, http.StatusConflict, "run is not running")
return
}
writeJSON(w, http.StatusOK, map[string]any{"stopped": true})
}
func (s *APIServer) handleSpecDeleteRun(w http.ResponseWriter, r *http.Request) {
run := s.specRuns.Get(r.PathValue("id"))
if run == nil {
writeError(w, http.StatusNotFound, "no such run")
return
}
if run.Status() == apiscan.StatusRunning {
writeError(w, http.StatusConflict, "stop the run before deleting it")
return
}
s.specRuns.Delete(run.ID)
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
// runWarnings reports conditions the operator should know about before a run
// rather than discover during one.
func (s *APIServer) runWarnings(total int) []string {
out := []string{}
// Every send goes through Joro's own proxy, so an armed intercept pauses each
// one in the operator's queue. At scan volumes that is not a small surprise.
if s.settings.InterceptEnabled {
out = append(out, fmt.Sprintf(
"request intercept is armed, so all %d requests will pause in the Intercept queue", total))
}
if total > 500 {
out = append(out, fmt.Sprintf(
"%d requests will be captured into History, which may evict older traffic from the capture buffer", total))
}
return out
}
func stripRaw(res apiscan.Result) apiscan.Result {
res.ReqRaw, res.RespRaw = nil, nil
return res
}
// selectOperations picks the named operations, preserving document order so a
// results table reads the way the tree does. An empty list means all of them.
func selectOperations(spec *apispec.Spec, ids []string) []apispec.Operation {
if len(ids) == 0 {
out := make([]apispec.Operation, len(spec.Operations))
copy(out, spec.Operations)
return out
}
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
var out []apispec.Operation
for _, op := range spec.Operations {
if want[op.ID] {
out = append(out, op)
}
}
return out
}
// selectProfiles resolves the named profiles.
//
// Asking for nothing falls back to one unauthenticated profile, which is what
// makes a plain scan a matrix with one profile rather than a separate code path.
// Asking for profiles that do not resolve must not reach that fallback: ids are
// renumbered on re-save, so a stale request is ordinary, and running a
// credentialed matrix anonymously reports every reachable endpoint as open.
func (s *APIServer) selectProfiles(specID string, ids []string) ([]apiscan.Profile, error) {
all := s.specStore.Profiles(specID)
if len(ids) == 0 {
if len(all) == 0 {
return []apiscan.Profile{{ID: "none", Label: "unauthenticated", Rank: 0}}, nil
}
return all, nil
}
want := make(map[string]bool, len(ids))
for _, id := range ids {
want[id] = true
}
var out []apiscan.Profile
for _, p := range all {
if want[p.ID] {
out = append(out, p)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("none of the requested auth profiles exist on this document; re-pick them")
}
if len(out) < len(ids) {
return nil, fmt.Errorf("%d of the %d requested auth profiles no longer exist on this document; re-pick them",
len(ids)-len(out), len(ids))
}
return out, nil
}
func serverFor(spec *apispec.Spec, index *int, scheme, host string, basePath *string) apispec.Server {
srv := apispec.Server{}
idx := 0
if index != nil {
idx = *index
}
if idx >= 0 && idx < len(spec.Servers) {
srv = spec.Servers[idx]
}
if scheme != "" {
srv.Scheme = scheme
}
if host != "" {
srv.Host = host
}
if basePath != nil {
srv.BasePath = *basePath
}
return srv
}
// normalizeMethods upper-cases a supplied whitelist. An empty one leaves the
// default, which excludes DELETE and PATCH.
func normalizeMethods(methods []string) []string {
if len(methods) == 0 {
return nil
}
out := make([]string, 0, len(methods))
for _, m := range methods {
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
out = append(out, m)
}
}
return out
}
func pageParams(r *http.Request, total int) (offset, limit int) {
limit = 500
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
return offset, limit
}
+58
View File
@@ -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.
+23
View File
@@ -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,
+414
View File
@@ -0,0 +1,414 @@
// Package apiscan drives requests built from an API description document.
//
// It is separate from internal/apispec on purpose. apispec is a leaf: it parses
// and projects, imports nothing from the rest of Joro, and cannot send. This
// package is where the worker pool, the rate limiter, the budget, the claim set
// and the event shapes live — none of which belong in a package whose reason to
// exist is that a parser for another description language can be a sibling file
// returning the same *apispec.Spec. Passing a send callback into apispec would
// satisfy the import rule while breaking that one. internal/fuzzer and
// internal/detect are split the same way.
//
// # One runner, not two
//
// A scan is an auth matrix with a single profile. The work item is a pair of one
// operation and one profile; a scan enqueues len(ops), a matrix enqueues
// len(ops) x len(profiles), and the execution is identical. Only the view
// differs. Two runners would duplicate the pool, the limiter, the claim set and
// the results shape, would drift, and would leave two places for the destructive
// gate to be forgotten.
//
// # Everything goes through Joro's own proxy
//
// Sends use httptools.SendViaProxy, so every request is captured into History,
// scanned by the detect engine, entered into the site map, filtered by scope and
// rewritten by Match & Replace and Custom Data. Scanning an API's whole surface
// is therefore the act that populates the rest of the tool, which is the reason
// this feature is worth having inside Joro rather than beside it. The costs come
// with it: HTTP/1.1 only, an armed request intercept pauses every send in the
// operator's queue, and a large sweep occupies rows in a capture ring buffer.
package apiscan
import (
"context"
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/httptools"
)
// Kind is what a run does.
type Kind string
const (
KindScan Kind = "scan"
KindMatrix Kind = "matrix"
KindDiscovery Kind = "discovery"
)
// Status is a run's lifecycle state.
type Status string
const (
StatusRunning Status = "running"
StatusComplete Status = "complete"
StatusStopped Status = "stopped"
)
// Caps. Sized against the fuzzer, the way httptools' batch caps are: past a few
// thousand rows the right answer is "drive the fuzzer", and the refusal says so.
const (
MaxRequestsPerRun = 2000
MaxProfiles = 8
MaxRuns = 20
MaxSpecs = 8
DefaultConcurrency = 4
MaxConcurrency = 20
MaxRatePerSec = 50
DefaultTimeoutMs = 10000
MaxTimeoutMs = 60000
DefaultBudgetMs = 600000
MaxBudgetMs = 1800000
// Discovery runs hotter than a scan: the candidates are unauthenticated GETs
// against one host, and a scan's default of 4 would make a full sweep crawl.
DefaultDiscoveryConcurrency = 10
MaxDiscoveryFollows = 50
MaxFollowDepth = 3
// ChallengeLimit stops a sweep once a WAF is clearly answering. Continuing
// spends thousands of requests to learn nothing, and looks exactly like the
// attack the WAF is there to stop.
ChallengeLimit = 20
)
// Profile is one authentication state a run tests under.
type Profile struct {
ID string `json:"id"`
Label string `json:"label"`
// Rank orders profiles by privilege, 0 being anonymous. It is what makes a
// matrix verdict a statement rather than a comparison of two arbitrary
// credential sets: "a lower rank reached what only a higher rank should"
// needs an ordering to mean anything.
Rank int `json:"rank"`
// Auth never leaves the process. A run result names the profile; it never
// carries what satisfied it.
Auth apispec.Auth `json:"-"`
}
// Config is everything a run needs, snapshotted at start so an edit to the spec
// mid-run cannot change what is being sent.
type Config struct {
Kind Kind
SpecID string
Server apispec.Server
Ops []apispec.Operation
Values map[string]apispec.Values // keyed by operation ID
Profiles []Profile
Concurrency int
RatePerSec float64
TimeoutMs int
BudgetMs int
UserAgent string
// AllowDestructive and Methods are the two halves of the safety gate. They
// are configuration rather than a parse-time skip, because the operator must
// be able to see the whole API surface and then tick the one they meant.
AllowDestructive bool
Methods []string
// Discovery only.
Scheme string
Host string
BasePath string
CandidateSet apispec.CandidateSet
StopOnFirst bool
}
// Triage is the three-band rubric, computed server-side and shipped on the
// result so the frontend never recomputes it — one fewer mirror to keep in sync.
type Triage string
const (
TriageGood Triage = "good" // 2xx: the request was accepted
TriageBad Triage = "bad" // 401/403/404: refused or absent
TriageWarn Triage = "warn" // everything else, including transport failure
)
// TriageOf applies the rubric. sj's colouring is the same shape; the names are
// chosen so the UI can pick glyph and colour without a second table.
func TriageOf(status int, err string) Triage {
if err != "" {
return TriageWarn
}
switch {
case status >= 200 && status < 300:
return TriageGood
case status == 401 || status == 403 || status == 404:
return TriageBad
default:
return TriageWarn
}
}
// Result is one work item's outcome. A skipped item still occupies its slot, so
// the client can correlate every row back to what it asked for.
type Result struct {
Index int `json:"index"`
OpID string `json:"opId,omitempty"`
ProfileID string `json:"profileId,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"`
// ReqPath is the origin-form actually rendered — path and query, every
// placeholder resolved. Path stays the document's template, because that is
// what a row correlates back to and what a skipped row has instead; ReqPath is
// what the operator reads to see the values that went out, without opening the
// row. Empty when the item never rendered.
ReqPath string `json:"reqPath,omitempty"`
// Seq and RequestID address the History row this send produced. Seq is 0 and
// RequestID empty when the send was not captured, which is a normal outcome
// — a noise-filtered host, or one outside the proxy's capture scope — and
// SeqNote says which.
Seq int `json:"seq"`
RequestID string `json:"requestId,omitempty"`
SeqNote string `json:"seqNote,omitempty"`
Status int `json:"status"`
Len int `json:"len"`
DurationMs int64 `json:"ms"`
BodyHash string `json:"bhash,omitempty"`
StructHash string `json:"shash,omitempty"`
Words int `json:"words,omitempty"`
Lines int `json:"lines,omitempty"`
Note string `json:"note,omitempty"`
Triage Triage `json:"triage"`
Error string `json:"error,omitempty"`
Skipped string `json:"skipped,omitempty"` // destructive | method | budget | render
// Discovery only.
ContentType string `json:"contentType,omitempty"`
BodyKind apispec.BodyKind `json:"bodyKind,omitempty"`
SpecTitle string `json:"specTitle,omitempty"`
SpecFormat string `json:"specFormat,omitempty"`
SpecOps int `json:"specOps,omitempty"`
ReqRaw []byte `json:"-"`
RespRaw []byte `json:"-"`
}
// Run is one execution. Counters are read while it is in flight, so every
// accessor takes the lock.
type Run struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
SpecID string `json:"specId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Total int `json:"total"`
Config Config `json:"-"`
mu sync.RWMutex
status Status
completed int
errors int
skipped int
hits int
challenges int
results []Result
cancel context.CancelFunc
}
// NewRun creates a run in the running state with its result slots pre-sized.
//
// Slots are indexed, never appended: idx = opIndex*len(profiles) + profileIndex.
// That makes the matrix pivot index arithmetic rather than a search, and it
// means a send that failed still occupies the slot the client asked about.
func NewRun(id string, cfg Config, total int) *Run {
return &Run{
ID: id,
Kind: cfg.Kind,
SpecID: cfg.SpecID,
CreatedAt: time.Now(),
Total: total,
Config: cfg,
status: StatusRunning,
results: newResults(cfg.Kind, total),
}
}
// newResults sizes the result slice for how the run fills it. A scan and a
// matrix write by index, so theirs is pre-sized; discovery appends, so a
// pre-sized slice would leave `total` blank rows in front of every real one.
func newResults(kind Kind, total int) []Result {
if kind == KindDiscovery {
return make([]Result, 0, total)
}
out := make([]Result, total)
// Each slot knows its position before anything fills it: a run is read while
// still going, and an unfilled slot carrying index 0 would claim row 0.
for i := range out {
out[i].Index = i
}
return out
}
// Status reports the run's state.
func (r *Run) Status() Status {
r.mu.RLock()
defer r.mu.RUnlock()
return r.status
}
// Counts reports progress.
func (r *Run) Counts() (completed, errors, skipped, hits int) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.completed, r.errors, r.skipped, r.hits
}
// Results returns a copy of the result slice with raw bodies stripped, which is
// what every list endpoint wants; raws are fetched one at a time by index.
func (r *Run) Results() []Result {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Result, len(r.results))
for i, res := range r.results {
res.ReqRaw, res.RespRaw = nil, nil
out[i] = res
}
return out
}
// ResultAt returns one result including its raw bodies.
func (r *Run) ResultAt(i int) (Result, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if i < 0 || i >= len(r.results) {
return Result{}, false
}
return r.results[i], true
}
// setResult stores one outcome and advances the counters.
//
// Takes a pointer because the caller broadcasts the same value it stored, and
// Index is the only thing addressing a row: stamping it on a copy would leave
// every emitted result at index 0, collapsing a whole run onto one row in the
// client. The signature is what carries that obligation to the next caller.
func (r *Run) setResult(i int, res *Result) {
r.mu.Lock()
defer r.mu.Unlock()
if i < 0 || i >= len(r.results) {
return
}
res.Index = i
r.results[i] = *res
r.completed++
switch {
case res.Error != "":
r.errors++
case res.Skipped != "":
r.skipped++
}
if res.BodyKind == apispec.KindSpec {
r.hits++
}
}
// appendResult stores an outcome whose index is not known in advance, which is
// discovery's shape: a followed reference is found mid-run, not enumerated.
// Pointer for the reason setResult is.
func (r *Run) appendResult(res *Result) {
r.mu.Lock()
defer r.mu.Unlock()
res.Index = len(r.results)
r.results = append(r.results, *res)
r.completed++
if res.Error != "" {
r.errors++
}
if res.BodyKind == apispec.KindSpec {
r.hits++
}
if r.completed > r.Total {
r.Total = r.completed
}
}
// noteChallenge counts a WAF interstitial and reports whether the run should
// give up. The counter lives on the Run rather than in a package variable, so
// two concurrent runs cannot exhaust each other's budget — sj keeps the
// equivalent as package state.
func (r *Run) noteChallenge() bool {
r.mu.Lock()
defer r.mu.Unlock()
r.challenges++
return r.challenges >= ChallengeLimit
}
func (r *Run) finish(s Status) {
r.mu.Lock()
defer r.mu.Unlock()
if r.status == StatusRunning {
r.status = s
}
}
// Stop cancels a running run.
func (r *Run) Stop() bool {
r.mu.Lock()
cancel := r.cancel
running := r.status == StatusRunning
if running {
r.status = StatusStopped
}
r.mu.Unlock()
if cancel != nil {
cancel()
}
return running
}
func (r *Run) setCancel(cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancel = cancel
}
// Deps is what a run needs from the host process.
type Deps struct {
// Send carries the proxy address, CA and capture store. Claims is filled in
// per run by Execute; a caller must not share one across runs.
Send httptools.SendDeps
// Broadcast is the hub's channel. It is chan<- any, matching
// api.Hub.Broadcast and fuzzer.Run, and carries event.WSEvent values.
Broadcast chan<- any
}
// clampInt keeps a client-supplied knob inside its bounds.
func clampInt(v, def, lo, hi int) int {
if v == 0 {
return def
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
+315
View File
@@ -0,0 +1,315 @@
package apiscan
import (
"context"
"fmt"
"net/url"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/httptools"
)
// PlanDiscovery builds the candidate list and validates the target.
func PlanDiscovery(cfg Config) ([]string, error) {
// The same rule the render path applies. Discovery builds its probes by
// concatenation, so without this it is the one send path with no guard on
// where it points.
if err := apispec.ValidateHost(cfg.Host); err != nil {
return nil, err
}
scheme := strings.ToLower(cfg.Scheme)
if scheme != "http" && scheme != "https" {
return nil, fmt.Errorf("scheme must be http or https")
}
paths := apispec.CandidatePaths(cfg.BasePath, cfg.CandidateSet)
if len(paths) == 0 {
return nil, fmt.Errorf("no candidates to try")
}
return paths, nil
}
// Discover sweeps a host for API description documents.
//
// Two differences from sj's brute, both deliberate. It reports every document it
// finds rather than stopping at the first: a tab has a list, and a target
// routinely serves /v2/api-docs alongside /v3/api-docs, or a public document
// beside an internal one, and the second is often the interesting one. And a
// reference discovered in a page is followed only when it stays on the run's own
// host — sj resolves any absolute reference, which lets a page the target
// controls redirect the sweep onto a third party.
func Discover(ctx context.Context, run *Run, paths []string, d Deps, onSpec func(*apispec.Spec, []byte)) {
cfg := run.Config
conc := clampInt(cfg.Concurrency, DefaultDiscoveryConcurrency, 1, MaxConcurrency)
perItem := time.Duration(clampInt(cfg.TimeoutMs, DefaultTimeoutMs, 1000, MaxTimeoutMs)) * time.Millisecond
budget := time.Duration(clampInt(cfg.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
runCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
run.setCancel(cancel)
var limiter <-chan time.Time
if cfg.RatePerSec > 0 {
rate := min(cfg.RatePerSec, MaxRatePerSec)
tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
defer tick.Stop()
limiter = tick.C
}
sendDeps := d.Send
sendDeps.Claims = httptools.NewClaimSet()
origin := cfg.Scheme + "://" + cfg.Host
start := time.Now()
broadcast(d, "spec.run.started", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "total": run.Total, "host": cfg.Host,
})
// seen guards both the enumerated candidates and anything followed out of a
// page, so a reference back to a path already tried costs nothing.
var seenMu sync.Mutex
seen := map[string]bool{}
follows := 0
claimFollow := func(u string) bool {
seenMu.Lock()
defer seenMu.Unlock()
if seen[u] || follows >= MaxDiscoveryFollows {
return false
}
seen[u] = true
follows++
return true
}
var stopped bool
var stopOnce sync.Once
halt := func(reason string) {
stopOnce.Do(func() {
stopped = true
broadcast(d, "spec.run.warning", map[string]any{
"runId": run.ID, "kind": "challenge", "detail": reason,
})
cancel()
})
}
// Declared before assignment because it follows references by calling itself.
var probe func(target string, depth int)
probe = func(target string, depth int) {
if runCtx.Err() != nil {
return
}
raw := "GET " + pathOf(target) + " HTTP/1.1\r\nHost: " + cfg.Host + "\r\n" +
"User-Agent: " + apispec.UserAgentOr(cfg.UserAgent) + "\r\n" +
"Accept: application/json, text/yaml, text/html, */*\r\n\r\n"
itemCtx, itemCancel := context.WithTimeout(runCtx, perItem)
sent, err := httptools.SendViaProxy(itemCtx, []byte(raw), cfg.Scheme, cfg.Host, sendDeps)
itemCancel()
res := Result{URL: target, Method: "GET", Path: pathOf(target)}
if err != nil {
res.Error = shortErr(err)
res.Triage = TriageWarn
res.BodyKind = apispec.KindNone
run.appendResult(&res)
emitResult(d, run, res)
return
}
applyFingerprint(&res, sent)
body := bodyOf(sent.RespRaw)
switch {
case apispec.LooksLikeChallenge(body):
res.BodyKind = apispec.KindChallenge
if run.noteChallenge() {
halt("the target is answering with a bot-check interstitial; further requests would learn nothing")
}
case apispec.ShouldSkipContentType(res.ContentType):
res.BodyKind = apispec.KindSkip
default:
res.BodyKind = classify(body, res.ContentType, target, run, d, onSpec, &res)
}
run.appendResult(&res)
emitResult(d, run, res)
// Follow references out of a UI page or an initializer.
if depth < MaxFollowDepth && (res.BodyKind == apispec.KindReference || res.BodyKind == apispec.KindNone) {
for _, ref := range apispec.ExtractSpecRefs(body, target) {
// Same-host only, and that is the whole guard. A reference
// off-origin is a scope escape, not a discovery — including the
// public sample document Swagger UI ships as its stock default,
// which is the case a separate demo-host filter used to catch.
// Such a filter is worse than redundant here: it can only ever be
// reached by a same-origin reference, so the one time it fires is
// when the operator is deliberately sweeping a sample host, and it
// refuses to follow that host's own initializer.
if !apispec.SameHost(ref, origin) {
continue
}
if !claimFollow(ref) {
continue
}
probe(ref, depth+1)
}
}
}
work := make(chan string, conc)
var wg sync.WaitGroup
for range conc {
wg.Add(1)
go func() {
defer wg.Done()
for target := range work {
if limiter != nil {
select {
case <-runCtx.Done():
return
case <-limiter:
}
}
if runCtx.Err() != nil {
return
}
probe(target, 0)
if cfg.StopOnFirst {
if _, _, _, hits := run.Counts(); hits > 0 {
cancel()
return
}
}
}
}()
}
for _, p := range paths {
full := origin + p
seenMu.Lock()
dup := seen[full]
seen[full] = true
seenMu.Unlock()
if dup {
continue
}
select {
case work <- full:
case <-runCtx.Done():
}
if runCtx.Err() != nil {
break
}
}
close(work)
wg.Wait()
if stopped {
run.finish(StatusStopped)
}
finishRun(d, run, start)
}
// classify decides what one discovery response turned out to be, loading any
// document it finds.
func classify(
body []byte, contentType, target string, run *Run, d Deps,
onSpec func(*apispec.Spec, []byte), res *Result,
) apispec.BodyKind {
if res.Status < 200 || res.Status >= 400 || len(body) == 0 {
return apispec.KindNone
}
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") || apispec.LooksLikeHTMLDocument(body)
if isHTML {
return apispec.KindReference
}
candidate := body
isJS := strings.Contains(strings.ToLower(contentType), "javascript") ||
strings.HasSuffix(strings.ToLower(pathOf(target)), ".js")
if isJS {
if !apispec.LooksLikeSwaggerInit(target) {
return apispec.KindSkip
}
unwrapped, ok := apispec.UnwrapJSSpec(body)
if !ok {
return apispec.KindReference
}
candidate = unwrapped
}
// Default placeholders, deliberately: a sweep parses a candidate only to
// report its title and operation count, neither of which depends on a
// generated value. The operator sets placeholders on the document once it is
// the one they are working, which re-parses it.
spec, err := apispec.ParseOpenAPI(candidate, apispec.Options{SourceURL: target})
if err != nil {
// Document-shaped but unparseable is still worth surfacing: it is
// frequently a truncated or auth-gated document, and the operator can
// judge. sj discards these silently.
if looksDocumentShaped(candidate) {
return apispec.KindWeak
}
return apispec.KindNone
}
if len(spec.Operations) == 0 {
return apispec.KindWeak
}
res.SpecTitle = spec.Title
res.SpecFormat = string(spec.Format)
res.SpecOps = len(spec.Operations)
if onSpec != nil {
onSpec(spec, candidate)
}
return apispec.KindSpec
}
// looksDocumentShaped reports whether bytes mention a version token at all, so a
// near-miss can be told from an unrelated 200.
func looksDocumentShaped(body []byte) bool {
window := body
if len(window) > 4096 {
window = window[:4096]
}
s := strings.ToLower(string(window))
return strings.Contains(s, "\"swagger\"") || strings.Contains(s, "\"openapi\"") ||
strings.Contains(s, "swagger:") || strings.Contains(s, "openapi:")
}
// bodyOf splits the body out of a raw response.
func bodyOf(raw []byte) []byte {
if i := strings.Index(string(raw), "\r\n\r\n"); i >= 0 {
return raw[i+4:]
}
if i := strings.Index(string(raw), "\n\n"); i >= 0 {
return raw[i+2:]
}
return nil
}
func pathOf(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.Path == "" {
return "/"
}
// EscapedPath, not Path: url.Parse percent-decodes into Path, and a followed
// reference carrying %0d%0a would come back as a real CRLF — straight into
// the request line below.
p := u.EscapedPath()
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
for i := 0; i < len(p); i++ {
if p[i] <= 0x20 || p[i] >= 0x7f {
return "/"
}
}
return p
}
+216
View File
@@ -0,0 +1,216 @@
package apiscan
import (
"sort"
"strings"
)
// Verdict is what one operation's row across every profile amounts to.
type Verdict string
const (
// VerdictOpen means the least-privileged profile — normally anonymous —
// was served. This is the single most valuable thing the matrix finds, and
// it needs no comparison at all.
VerdictOpen Verdict = "open"
// VerdictBroken means a lower-privileged profile got the same response a
// higher-privileged one did, while some profile below it was refused. That
// is broken access control: the endpoint distinguishes *some* callers, so it
// is not simply public, but it fails to distinguish the one that matters.
VerdictBroken Verdict = "broken"
// VerdictDenied means every profile was refused.
VerdictDenied Verdict = "denied"
// VerdictExpected means authorization rises with privilege, as intended.
VerdictExpected Verdict = "expected"
// VerdictError means a cell could not be evaluated.
VerdictError Verdict = "error"
)
// Cell is one operation under one profile.
type Cell struct {
ProfileID string `json:"profileId"`
Rank int `json:"rank"`
Status int `json:"status"`
Len int `json:"len"`
BodyHash string `json:"bhash,omitempty"`
StructHash string `json:"shash,omitempty"`
DurationMs int64 `json:"ms"`
Seq int `json:"seq"`
RequestID string `json:"requestId,omitempty"`
Triage Triage `json:"triage"`
Index int `json:"index"`
Error string `json:"error,omitempty"`
Skipped string `json:"skipped,omitempty"`
}
// Row is one operation across every profile, in ascending privilege order.
type Row struct {
OpID string `json:"opId"`
Method string `json:"method"`
Path string `json:"path"`
Cells []Cell `json:"cells"`
Verdict Verdict `json:"verdict"`
Detail string `json:"detail,omitempty"`
}
// MatrixView is the pivoted result.
type MatrixView struct {
RunID string `json:"runId"`
Profiles []Profile `json:"profiles"`
Rows []Row `json:"rows"`
Summary map[string]int `json:"summary"`
Interesting []string `json:"interesting"`
}
// Matrix pivots a run's results into the operations-by-profiles grid and
// computes each row's verdict.
//
// The verdict is computed here rather than in the client for the reason
// httptools.renderBatch states about its outliers line: the answer is
// precomputed and the table is corroboration. Deciding it in TypeScript would
// put privilege comparison in two places and create a keep-in-sync pair nobody
// maintains.
func Matrix(run *Run) MatrixView {
cfg := run.Config
results := run.Results()
// Profiles are compared in privilege order, which is what makes "a lower
// rank reached what only a higher rank should" a statement about privilege
// rather than about two arbitrary credential sets.
order := make([]int, len(cfg.Profiles))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool {
return cfg.Profiles[order[a]].Rank < cfg.Profiles[order[b]].Rank
})
ordered := make([]Profile, len(order))
for i, idx := range order {
ordered[i] = cfg.Profiles[idx]
}
// Materialized rather than left nil: a nil slice marshals as null, and the
// client's type says array. See apispec.Spec.normalize for the rule.
view := MatrixView{
RunID: run.ID, Profiles: ordered,
Rows: []Row{}, Interesting: []string{}, Summary: map[string]int{},
}
if len(cfg.Profiles) == 0 {
return view
}
for opIdx := range cfg.Ops {
op := cfg.Ops[opIdx]
row := Row{OpID: op.ID, Method: op.Method, Path: op.Path, Cells: []Cell{}}
for _, profIdx := range order {
idx := opIdx*len(cfg.Profiles) + profIdx
if idx >= len(results) {
continue
}
r := results[idx]
row.Cells = append(row.Cells, Cell{
ProfileID: cfg.Profiles[profIdx].ID, Rank: cfg.Profiles[profIdx].Rank,
Status: r.Status, Len: r.Len, BodyHash: r.BodyHash, StructHash: r.StructHash,
DurationMs: r.DurationMs, Seq: r.Seq, RequestID: r.RequestID,
Triage: r.Triage, Index: r.Index, Error: r.Error, Skipped: r.Skipped,
})
}
row.Verdict, row.Detail = verdictFor(row.Cells)
view.Summary[string(row.Verdict)]++
if row.Verdict == VerdictOpen || row.Verdict == VerdictBroken {
view.Interesting = append(view.Interesting, op.ID)
}
view.Rows = append(view.Rows, row)
}
return view
}
// verdictFor classifies one row. Cells arrive in ascending privilege order.
func verdictFor(cells []Cell) (Verdict, string) {
var usable []Cell
firstErr := ""
for _, c := range cells {
if c.Error != "" {
// Held, not returned: a failure in a privileged column must not bury
// "anonymous was served", which needs no comparison. Still an error
// for every rule that does need the whole row.
if firstErr == "" {
firstErr = c.Error
}
continue
}
if c.Skipped != "" {
continue
}
// A slot that has not been filled yet is the zero Result: no error, no
// skip reason, and an empty Triage. Treating it as usable would walk it
// past every rule below and land on VerdictExpected — reporting an
// operation that was never sent as correctly authorized. For a grid whose
// whole purpose is finding access-control failures, failing open is the
// wrong direction, and GET /runs/{id}/matrix can be called mid-run.
if c.Triage == "" {
return VerdictError, "this operation has not been sent yet"
}
usable = append(usable, c)
}
if len(usable) == 0 {
if firstErr != "" {
return VerdictError, "a request could not be completed: " + firstErr
}
return VerdictError, "no request was sent for this operation"
}
// The least-privileged profile being served needs no comparison. cells[0],
// not usable[0]: if the anonymous column is the one that errored, usable[0]
// holds credentials and calling its 200 "open" invents a finding.
if len(cells) > 0 && cells[0].Error == "" && cells[0].Skipped == "" && cells[0].Triage == TriageGood {
return VerdictOpen, "the least-privileged profile was served"
}
if firstErr != "" {
return VerdictError, "a request could not be completed: " + firstErr
}
allDenied := true
for _, c := range usable {
if c.Triage != TriageBad {
allDenied = false
break
}
}
if allDenied {
return VerdictDenied, "every profile was refused"
}
// Broken access control: the most-privileged profile was served, a
// less-privileged one got a structurally identical response, and something
// below that was refused.
//
// StructHash rather than status and length is what makes this work. Two
// renderings of one page carrying different CSRF tokens have different
// lengths and different body hashes but the same struct hash, so a
// length comparison misses the finding and a body comparison flags every
// row.
top := usable[len(usable)-1]
if top.Triage == TriageGood && top.StructHash != "" {
for i := 0; i < len(usable)-1; i++ {
lower := usable[i]
if lower.Triage != TriageGood || lower.StructHash != top.StructHash {
continue
}
for j := 0; j < i; j++ {
if usable[j].Triage == TriageBad {
return VerdictBroken, strings.Join([]string{
"profile", lower.ProfileID, "received the same response as", top.ProfileID,
"while", usable[j].ProfileID, "was refused",
}, " ")
}
}
}
}
return VerdictExpected, ""
}
+272
View File
@@ -0,0 +1,272 @@
package apiscan
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
"github.com/BishopFox/joro/internal/event"
"github.com/BishopFox/joro/internal/httptools"
)
// Plan reports how many requests a config will send, and refuses one that is
// too large before any of it goes on the wire.
func Plan(cfg Config) (int, error) {
if len(cfg.Ops) == 0 {
return 0, fmt.Errorf("no operations selected")
}
if len(cfg.Profiles) == 0 {
return 0, fmt.Errorf("no auth profiles selected; use a single empty profile for an unauthenticated scan")
}
if len(cfg.Profiles) > MaxProfiles {
return 0, fmt.Errorf("%d profiles exceeds the limit of %d", len(cfg.Profiles), MaxProfiles)
}
total := len(cfg.Ops) * len(cfg.Profiles)
if total > MaxRequestsPerRun {
return 0, fmt.Errorf(
"%d operations x %d profiles is %d requests, over the %d-request limit; "+
"select fewer operations, or drive Joro's fuzzer from the Fuzz tab for a run that size",
len(cfg.Ops), len(cfg.Profiles), total, MaxRequestsPerRun)
}
return total, nil
}
// Execute runs a scan or a matrix.
//
// The shape mirrors httptools.Batch: one shared ticker as the rate limiter, a
// channel of indices, N workers, one claim set for the whole run, and results
// written into pre-sized slots by index rather than appended. Batch's own doc
// comment records that duplicating this shape is the repo's answer to the
// fuzzer's runner not being extractable, and the same reasoning applies here.
func Execute(ctx context.Context, run *Run, d Deps) {
cfg := run.Config
conc := clampInt(cfg.Concurrency, DefaultConcurrency, 1, MaxConcurrency)
perItem := time.Duration(clampInt(cfg.TimeoutMs, DefaultTimeoutMs, 1000, MaxTimeoutMs)) * time.Millisecond
budget := time.Duration(clampInt(cfg.BudgetMs, DefaultBudgetMs, 1000, MaxBudgetMs)) * time.Millisecond
runCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
run.setCancel(cancel)
var limiter <-chan time.Time
if cfg.RatePerSec > 0 {
rate := min(cfg.RatePerSec, MaxRatePerSec)
tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
defer tick.Stop()
limiter = tick.C
}
// One claim set for the run, so two workers finishing at once cannot
// correlate their sends to the same History row. In a matrix this is not an
// edge case: two profiles hitting one operation often produce byte-identical
// responses, which is exactly when correlate falls through to claim order.
sendDeps := d.Send
sendDeps.Claims = httptools.NewClaimSet()
methods := cfg.Methods
if len(methods) == 0 {
methods = apispec.DefaultMethods()
}
start := time.Now()
broadcast(d, "spec.run.started", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "total": run.Total,
"profiles": profileSummaries(cfg.Profiles),
})
total := len(cfg.Ops) * len(cfg.Profiles)
work := make(chan int, conc)
var wg sync.WaitGroup
for range conc {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range work {
opIdx := idx / len(cfg.Profiles)
profIdx := idx % len(cfg.Profiles)
op := &cfg.Ops[opIdx]
profile := cfg.Profiles[profIdx]
res := Result{
OpID: op.ID, ProfileID: profile.ID,
Method: op.Method, Path: op.Path,
}
// The safety gate. A skipped item still fills its slot, so the
// client can correlate every row back to what it asked for and can
// see why one was not sent.
if !slices.Contains(methods, strings.ToUpper(op.Method)) {
res.Skipped = "method"
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
if op.Destructive && !cfg.AllowDestructive {
res.Skipped = "destructive"
res.Note = strings.Join(op.DestructiveReasons, ", ")
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
if limiter != nil {
select {
case <-runCtx.Done():
res.Skipped = "budget"
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
case <-limiter:
}
}
if runCtx.Err() != nil {
res.Skipped = "budget"
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
raw, target, err := apispec.Render(op, cfg.Server, cfg.Values[op.ID], profile.Auth,
apispec.RenderOptions{UserAgent: cfg.UserAgent})
if err != nil {
res.Skipped = "render"
res.Error = err.Error()
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
res.URL = target.Scheme + "://" + target.Host + target.Path
res.ReqPath = target.Path
res.ReqRaw = raw
itemCtx, itemCancel := context.WithTimeout(runCtx, perItem)
sent, err := httptools.SendViaProxy(itemCtx, raw, target.Scheme, target.Host, sendDeps)
itemCancel()
if err != nil {
res.Error = shortErr(err)
res.Triage = TriageWarn
run.setResult(idx, &res)
emitResult(d, run, res)
continue
}
applyFingerprint(&res, sent)
run.setResult(idx, &res)
emitResult(d, run, res)
}
}()
}
for i := range total {
select {
case work <- i:
case <-runCtx.Done():
// Fill the rest so the client is not left waiting on rows that will
// never arrive.
for j := i; j < total; j++ {
op := &cfg.Ops[j/len(cfg.Profiles)]
res := Result{
Skipped: "budget", Triage: TriageWarn,
OpID: op.ID, Method: op.Method, Path: op.Path,
ProfileID: cfg.Profiles[j%len(cfg.Profiles)].ID,
}
run.setResult(j, &res)
emitResult(d, run, res)
}
close(work)
wg.Wait()
finishRun(d, run, start)
return
}
}
close(work)
wg.Wait()
finishRun(d, run, start)
}
// applyFingerprint copies a send's outcome onto a result.
//
// FingerprintResponse rather than status and length: StructHash folds out CSRF
// tokens, timestamps, UUIDs and nonces, which is what stops an auth matrix
// flagging every row where two renderings of one page differ only in a nonce.
func applyFingerprint(res *Result, sent *httptools.ProxySendResult) {
fp := httptools.FingerprintResponse(sent.Seq, sent.RespRaw, sent.Duration.Milliseconds(), false)
res.Seq = sent.Seq
res.RequestID = sent.RequestID
res.SeqNote = sent.SeqNote
res.Status = fp.Status
res.Len = fp.Len
res.DurationMs = fp.DurationMs
res.BodyHash = fp.BodyHash
res.StructHash = fp.StructHash
res.Words = fp.Words
res.Lines = fp.Lines
res.Note = fp.Note
res.ContentType = fp.CT
res.RespRaw = sent.RespRaw
res.Triage = TriageOf(fp.Status, "")
}
func finishRun(d Deps, run *Run, start time.Time) {
status := StatusComplete
if run.Status() == StatusStopped {
status = StatusStopped
}
run.finish(status)
completed, errs, skipped, hits := run.Counts()
broadcast(d, "spec.run.complete", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "status": string(run.Status()),
"completed": completed, "errors": errs, "skipped": skipped, "hits": hits,
"durationMs": time.Since(start).Milliseconds(),
})
}
// emitResult sends one row.
//
// Blocking, from the run's own goroutine, like fuzzer.result: Hub.Subscribe fans
// out to in-process consumers without blocking, so a slow subscriber cannot
// stall the run, and sending blocking here is what keeps the client's row count
// agreeing with the run's total.
func emitResult(d Deps, run *Run, res Result) {
res.ReqRaw, res.RespRaw = nil, nil
broadcast(d, "spec.run.result", map[string]any{
"runId": run.ID, "kind": string(run.Kind), "result": res,
})
}
func broadcast(d Deps, typ string, data map[string]any) {
if d.Broadcast == nil {
return
}
d.Broadcast <- event.WSEvent{Type: typ, Data: data}
}
func profileSummaries(profiles []Profile) []map[string]any {
out := make([]map[string]any, 0, len(profiles))
for _, p := range profiles {
out = append(out, map[string]any{"id": p.ID, "label": p.Label, "rank": p.Rank})
}
return out
}
func shortErr(err error) string {
msg := err.Error()
if i := strings.LastIndex(msg, ": "); i > 0 && len(msg)-i < 60 {
return msg[i+2:]
}
if len(msg) > 120 {
return msg[:120]
}
return msg
}
+195
View File
@@ -0,0 +1,195 @@
package apiscan
import (
"sync"
"time"
"github.com/BishopFox/joro/internal/apispec"
)
// Store holds runs in memory, bounded like fuzzer.Store. Runs are session state
// and never reach the project file.
type Store struct {
mu sync.RWMutex
runs []*Run
max int
}
// NewStore creates an empty run store.
func NewStore() *Store { return &Store{max: MaxRuns} }
// Add registers a run, evicting the oldest finished one when full. A running run
// is never evicted: its workers hold a pointer to it, and dropping it from the
// store would only make it unreachable, not stop it.
func (s *Store) Add(r *Run) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.runs) >= s.max {
for i, old := range s.runs {
if old.Status() != StatusRunning {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
break
}
}
}
s.runs = append(s.runs, r)
}
// Get returns a run by ID, or nil.
func (s *Store) Get(id string) *Run {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.runs {
if r.ID == id {
return r
}
}
return nil
}
// List returns every run, newest first.
func (s *Store) List() []*Run {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*Run, len(s.runs))
for i, r := range s.runs {
out[len(s.runs)-1-i] = r
}
return out
}
// Delete removes a run, reporting whether it was found.
func (s *Store) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, r := range s.runs {
if r.ID == id {
s.runs = append(s.runs[:i], s.runs[i+1:]...)
return true
}
}
return false
}
// StoredSpec is a parsed document plus where it came from.
type StoredSpec struct {
Spec *apispec.Spec `json:"spec"`
LoadedAt time.Time `json:"loadedAt"`
// Source is the original bytes, kept so the operator can read the document
// they are working against. It is served by its own endpoint rather than
// riding on every spec response, because it is routinely megabytes.
Source []byte `json:"-"`
}
// SpecStore holds parsed documents and the auth profiles defined against them.
//
// In memory only. A document fetched by URL went through the proxy and is
// therefore already in History, so persisting it would store it twice; and
// profiles hold credentials, which is why webhooks.json's secrets are kept out
// of the project file too.
type SpecStore struct {
mu sync.RWMutex
specs []*StoredSpec
profiles map[string][]Profile // keyed by spec ID
max int
}
// NewSpecStore creates an empty spec store.
func NewSpecStore() *SpecStore {
return &SpecStore{profiles: map[string][]Profile{}, max: MaxSpecs}
}
// Put stores a parsed document, replacing an existing one with the same ID. The
// ID is a hash of the source bytes, so re-loading an unchanged document keeps
// the profiles already defined against it.
//
// The same ID does not imply the same generated defaults: one source parsed
// under two sets of apispec.Placeholders yields two Specs sharing an ID, and the
// last Put wins. Anything that re-parses stored source therefore has to mean it
// — which is why discovery only Puts a document not already held.
func (s *SpecStore) Put(spec *apispec.Spec, source []byte) {
s.mu.Lock()
defer s.mu.Unlock()
for i, existing := range s.specs {
if existing.Spec.ID == spec.ID {
// Moved to the back, not updated in place. Eviction below takes
// specs[0], so position is the eviction order; refreshing LoadedAt
// without moving the entry left a document the operator reloads
// constantly still first in line to be dropped — out from under the
// tab still using it, whose every later render and scan then 404s.
s.specs = append(append(s.specs[:i:i], s.specs[i+1:]...),
&StoredSpec{Spec: spec, Source: source, LoadedAt: time.Now()})
return
}
}
if len(s.specs) >= s.max {
evicted := s.specs[0]
delete(s.profiles, evicted.Spec.ID)
s.specs = s.specs[1:]
}
s.specs = append(s.specs, &StoredSpec{Spec: spec, Source: source, LoadedAt: time.Now()})
}
// Get returns a stored document by ID, or nil.
func (s *SpecStore) Get(id string) *StoredSpec {
s.mu.RLock()
defer s.mu.RUnlock()
for _, stored := range s.specs {
if stored.Spec.ID == id {
return stored
}
}
return nil
}
// List returns every stored document, newest first.
func (s *SpecStore) List() []*StoredSpec {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*StoredSpec, len(s.specs))
for i, stored := range s.specs {
out[len(s.specs)-1-i] = stored
}
return out
}
// Delete removes a document and the profiles defined against it.
func (s *SpecStore) Delete(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for i, stored := range s.specs {
if stored.Spec.ID == id {
s.specs = append(s.specs[:i], s.specs[i+1:]...)
delete(s.profiles, id)
return true
}
}
return false
}
// SetProfiles replaces the profile set for one document.
func (s *SpecStore) SetProfiles(specID string, profiles []Profile) {
s.mu.Lock()
defer s.mu.Unlock()
s.profiles[specID] = profiles
}
// Profiles returns a copy of the profile set for one document, credentials
// included. Callers that serialize these must strip Auth; it is json:"-" so the
// encoder does that on its own.
func (s *SpecStore) Profiles(specID string) []Profile {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Profile, len(s.profiles[specID]))
copy(out, s.profiles[specID])
return out
}
// Clear drops every document and profile, for a project switch.
func (s *SpecStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.specs = nil
s.profiles = map[string][]Profile{}
}
+418
View File
@@ -0,0 +1,418 @@
// Package apispec parses API description documents into one normalized model and
// projects that model into raw HTTP requests.
//
// The parsing and placeholder-value approach is ported from BishopFox's sj
// (github.com/BishopFox/sj), which audits exposed Swagger/OpenAPI documents by
// building a request for every declared operation. See the notes on individual
// functions for the places this deliberately diverges.
//
// # The spec is the editing document; the raw request is the executable truth
//
// Nothing here sends. Render takes no context.Context and this package imports
// nothing that can open a socket, so that is structural rather than a
// convention. Render turns one operation plus a set of values into the exact
// bytes that go on the wire, and those bytes are what the operator sees, edits,
// and hands to the senders that already exist.
//
// The consequence is the point: because a spec-driven request is just bytes,
// scope, Match & Replace, Custom Data and intercept all apply to it without this
// package knowing they exist, and a spec-driven request can be handed to the
// request workbench or the fuzzer with nothing lost in translation. A send path
// private to the spec tab is what would break it.
//
// # A $ref never reaches the filesystem
//
// sj resolves external file references ("./schemas/user.yaml#/User") off local
// disk. There, the document is a file the operator chose. Here, a document is
// routinely fetched from a target and is therefore attacker-controlled, and
// following a file reference would not merely be arbitrary file read: the value
// read is rendered into a request body and sent to a host the same document
// chose, which makes it exfiltration. Local "#/..." pointers only. Everything
// else is refused and reported as a Diagnostic.
//
// # A document always parses to something
//
// Parse errors are reserved for input that is not a document at all. Everything
// survivable — a refused reference, a dangling pointer, a cycle, an exhausted
// budget, a duplicate key — degrades that one schema and appends a Diagnostic.
// An operator reading a 400 from a generated request needs to know the body came
// from a schema that could not be fully resolved; the alternative is assuming the
// endpoint rejects valid input.
package apispec
// Format identifies the kind of document a Spec was parsed from. The normalized
// model is deliberately free of OpenAPI vocabulary so that a parser for another
// description language is a sibling file returning the same *Spec.
type Format string
const (
FormatSwagger2 Format = "swagger2"
FormatOpenAPI3 Format = "openapi3"
)
// Limits bound what a hostile or merely enormous document can cost. They are
// fields rather than constants only where a caller has a legitimate reason to
// differ; the defaults are what DefaultOptions supplies.
const (
// MaxSpecBytes is checked against the raw bytes before any decode. Sized
// against real documents rather than a round number: GitHub's is 13 MB and
// Stripe's 3.7 MB, so 8 MB refused the two largest public APIs outright.
// MaxDocNodes is the real memory backstop; this only bounds the read.
MaxSpecBytes = 24 << 20
// MaxDocNodes bounds the decoded document. yaml.v3 has its own alias-ratio
// guard against billion-laughs (see decodeDocument), but that is a ratio and
// not a memory cap, so a large document can pass it and still expand.
MaxDocNodes = 2_000_000
// MaxRefDepth and MaxSchemaNodes bound schema expansion. A cycle is caught by
// the reference stack; these catch allOf/oneOf nesting that blows up
// combinatorially with no cycle anywhere.
MaxRefDepth = 32
MaxSchemaNodes = 50_000
// MaxPointerSegments bounds one JSON pointer.
MaxPointerSegments = 64
// MaxOperations bounds one document's operation count. Reaching it is
// reported as a Diagnostic rather than silently truncating.
MaxOperations = 2000
)
// Spec is a parsed API description, normalized away from its source format.
// Everything an operator can edit or send lives here; nothing here knows how the
// document was written.
type Spec struct {
ID string `json:"id"`
Format Format `json:"format"`
Version string `json:"version"` // the document's own token: "2.0", "3.0.1"
Title string `json:"title"`
Description string `json:"description,omitempty"`
APIVersion string `json:"apiVersion,omitempty"`
Servers []Server `json:"servers"`
Operations []Operation `json:"operations"`
// Auth is what the document says exists. It never holds a secret: a
// credential is supplied per render, as an Auth value.
Auth []AuthScheme `json:"auth"`
Security []SecurityRequirement `json:"security,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
SizeBytes int `json:"sizeBytes"`
// Placeholders are the effective values, after withDefaults, that generated
// every Param.Default and every body in this Spec.
//
// Recorded rather than left implicit because the UI shows a generated value
// as an input hint. A Spec that did not state its own generation inputs would
// let that hint and the bytes on the wire disagree with nothing to compare
// them against — the failure this package's "bytes are truth" rule exists to
// prevent. It is also what lets two views of one document agree that they are
// looking at the same parse.
Placeholders Placeholders `json:"placeholders"`
}
// normalize materializes every collection a client iterates unconditionally.
//
// # A nil slice and an empty slice are the same value in Go and different values on the wire
//
// encoding/json writes a nil slice as null, not []. So every collection crossing
// the API boundary has to be one of exactly two things:
//
// - omitempty, so an empty one is *absent* and the client's field is optional; or
// - guaranteed non-nil here, so it is [] and the client's field is a plain array.
//
// A field that is both non-omitempty and nillable is the combination that ships
// null against a type promising an array, and it is invisible to both compilers:
// Go sees a legal nil slice, TypeScript sees a declaration it has no way to
// check. That combination is what crashed the operations view on any document
// with a requestBody-only operation — the commonest shape in OpenAPI 3.
//
// Adding a collection field to Spec or Operation means choosing one of the two,
// and if it is the second, adding it here. The corpus test pins this by
// asserting the marshalled document contains no null for these fields.
func (s *Spec) normalize() {
if s.Servers == nil {
s.Servers = []Server{}
}
if s.Operations == nil {
s.Operations = []Operation{}
}
if s.Auth == nil {
s.Auth = []AuthScheme{}
}
for i := range s.Operations {
if s.Operations[i].Params == nil {
s.Operations[i].Params = []Param{}
}
}
}
// Server is one declared origin. A document may name several, or name one
// relative to wherever it was served from; both are reported rather than
// resolved to a single answer, because picking for the operator is how a scan
// ends up pointed at the wrong host.
type Server struct {
URL string `json:"url"` // exactly as written
Scheme string `json:"scheme,omitempty"`
Host string `json:"host,omitempty"` // host[:port]
BasePath string `json:"basePath"` // "" or "/v1", never trailing "/"
Description string `json:"description,omitempty"`
Vars map[string]string `json:"vars,omitempty"`
}
// Operation is one method on one path.
type Operation struct {
ID string `json:"id"` // operationId when usable, else "get:/pets/{id}"
Method string `json:"method"`
Path string `json:"path"` // the template, braces intact
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
Params []Param `json:"params"`
// Bodies holds one entry per declared content type, in document order.
// Bodies[0] is what Render uses unless Values selects another. sj keeps only
// the last one a map iteration happened to yield.
Bodies []Body `json:"bodies,omitempty"`
BodyRequired bool `json:"bodyRequired,omitempty"`
// Responses is keyed exactly as the document wrote it ("200", "4XX",
// "default"). String-keyed on purpose: sj builds this with an any key and
// then looks it up with an int, so the description never resolves.
Responses map[string]string `json:"responses,omitempty"`
// Security nil means "inherit Spec.Security". A non-nil empty slice means the
// operation explicitly opts out of authentication, which is a meaningful
// statement and not the same thing.
//
// No omitempty, deliberately: it omits a non-nil empty slice exactly as it
// omits a nil one, which would erase on the wire the very distinction the
// lines above take care to build.
Security []SecurityRequirement `json:"security"`
// Destructive is advisory, computed by Classify. It never stops a render; it
// gates a send.
Destructive bool `json:"destructive,omitempty"`
DestructiveReasons []string `json:"destructiveReasons,omitempty"`
// Servers is the operation's own server list, from the operation or its path
// item. Empty means "use the document's". OpenAPI 3 allows the override and
// multi-service gateways use it.
Servers []Server `json:"servers,omitempty"`
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
// ParamIn is where a parameter is carried.
type ParamIn string
const (
InPath ParamIn = "path"
InQuery ParamIn = "query"
InHeader ParamIn = "header"
InCookie ParamIn = "cookie"
)
// Param is one input to an operation.
type Param struct {
Name string `json:"name"`
In ParamIn `json:"in"`
Required bool `json:"required,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
Format string `json:"format,omitempty"`
Enum []string `json:"enum,omitempty"`
// Default is the value the parser generated, already in wire form. An
// operator override arrives through Values and never mutates this, so the UI
// can keep showing it as the placeholder after the field is cleared.
Default string `json:"default"`
// ExplodedFrom names the object-schema parameter this one was split out of.
// An OpenAPI 3 query parameter whose schema is an object becomes one Param
// per property, which is the only place the parser invents a name. Render
// regroups them by this to rebuild the wire form the style calls for.
ExplodedFrom string `json:"explodedFrom,omitempty"`
// Style and Explode select the serialization. Render reads both; the pair
// decides whether an object goes out as "?a=1&b=2", "?o=a,1,b,2" or
// "?o[a]=1&o[b]=2", which are three different requests.
Style string `json:"style,omitempty"`
Explode bool `json:"explode,omitempty"`
// ContentType is set when the document declared the parameter with `content`
// rather than `schema`, meaning its value is a serialized media type.
ContentType string `json:"contentType,omitempty"`
}
// BodyEncoding is how a Body's bytes were built, which is what the UI needs to
// choose an editor and what Render needs to leave alone.
type BodyEncoding string
const (
EncJSON BodyEncoding = "json"
EncXML BodyEncoding = "xml"
EncForm BodyEncoding = "form"
EncMultipart BodyEncoding = "multipart"
EncRaw BodyEncoding = "raw"
)
// Body is one request body variant.
type Body struct {
ContentType string `json:"contentType"`
Encoding BodyEncoding `json:"encoding"`
Content []byte `json:"content"`
Fields []string `json:"fields,omitempty"`
// Boundary is stored with the bytes it framed. Regenerating one at render
// time, as sj does, produces a Content-Type that does not describe the body.
Boundary string `json:"boundary,omitempty"`
}
// AuthKind is a security scheme's type, normalized across v2 and v3 spellings.
type AuthKind string
const (
AuthBasic AuthKind = "basic"
AuthBearer AuthKind = "bearer"
AuthAPIKey AuthKind = "apiKey"
AuthHTTPOther AuthKind = "http"
AuthOAuth2 AuthKind = "oauth2"
AuthOpenID AuthKind = "openIdConnect"
AuthMutualTLS AuthKind = "mutualTLS"
)
// AuthScheme is one way the document says a request may authenticate.
//
// Both spellings are read: OpenAPI 3's components.securitySchemes and Swagger
// 2's securityDefinitions. sj reads only the former, so a v2 document gets no
// authentication at all.
type AuthScheme struct {
Name string `json:"name"` // the key under the document's scheme map
Kind AuthKind `json:"kind"`
In ParamIn `json:"in,omitempty"` // apiKey only
ParamName string `json:"paramName,omitempty"` // apiKey only
Scheme string `json:"scheme,omitempty"` // raw http scheme token
BearerFormat string `json:"bearerFormat,omitempty"`
Description string `json:"description,omitempty"`
// Automatable is false for oauth2, openIdConnect and mutualTLS: Joro cannot
// obtain those credentials, only carry one the operator supplied.
Automatable bool `json:"automatable"`
}
// SecurityRequirement is one alternative: every scheme named in it must be
// satisfied. A slice of these is a disjunction.
type SecurityRequirement struct {
Schemes []string `json:"schemes"`
Scopes map[string][]string `json:"scopes,omitempty"`
}
// DiagKind classifies what could not be done.
type DiagKind string
const (
DiagExternalRef DiagKind = "external_ref" // refused, never fetched
DiagUnresolvedRef DiagKind = "unresolved_ref" // "#/..." pointing at nothing
DiagRefCycle DiagKind = "ref_cycle"
DiagDepthExceeded DiagKind = "depth_exceeded"
DiagNodeBudget DiagKind = "node_budget"
DiagDuplicateKey DiagKind = "duplicate_key"
DiagTruncated DiagKind = "truncated"
DiagUnsupported DiagKind = "unsupported"
DiagMalformed DiagKind = "malformed"
)
// Diagnostic is one thing the parser refused or could not complete.
type Diagnostic struct {
Kind DiagKind `json:"kind"`
Ref string `json:"ref,omitempty"` // the offending $ref, member or content type
Pointer string `json:"pointer,omitempty"` // where: "/paths/~1pets/get/requestBody"
Detail string `json:"detail,omitempty"`
}
// Values overrides the parser's generated defaults for one render. Keys are
// ValueKey(in, name); the reserved keys "body" and "contentType" carry the whole
// body and the selection among Operation.Bodies. An absent key means "use what
// the parser generated"; a present key with an empty value means "send it empty",
// which is a different and useful test.
type Values map[string]string
// ValueKey builds the Values key for one parameter.
func ValueKey(in ParamIn, name string) string { return string(in) + ":" + name }
// Reserved Values keys.
const (
ValueKeyBody = "body"
ValueKeyContentType = "contentType"
)
// Options tunes a parse.
type Options struct {
// SourceURL is where the document was served from. It is the base for a
// relative server URL and is recorded on the Spec; it is never fetched.
SourceURL string
// Placeholders supplies the generated values. The zero value is filled in
// from DefaultPlaceholders.
//
// A placeholder is an input to the *parse*, not to a render: it is baked into
// Param.Default and into body bytes, which no per-request substitution
// reaches. So changing one means parsing the document again, and the effective
// values are recorded on the resulting Spec. RenderOptions deliberately has no
// counterpart — a per-request placeholder would re-decode a multi-megabyte
// document on every preview, and would move the wire bytes while the UI kept
// showing the values of the parse it still holds.
Placeholders Placeholders
// DestructiveWhitelist names keywords to stop treating as destructive, for a
// target whose vocabulary collides with the list ("order" on a commerce API
// that only reads orders).
DestructiveWhitelist []string
}
// Placeholders are the values generated for a schema that declares no example
// and no enum. The defaults match sj's, so a Joro-generated request and an
// sj-generated request for the same document agree.
type Placeholders struct {
String string `json:"string"` // sj: --test-string, default "bishopfox"
Date string `json:"date"` // sj: -d, default "1990-01-01"
URL string `json:"url"` // sj: -c, default "https://bishopfox.com"
Email string `json:"email"` // sj: --custom-email
}
// DefaultPlaceholders returns sj's defaults.
func DefaultPlaceholders() Placeholders {
return Placeholders{
String: "bishopfox",
Date: "1990-01-01",
URL: "https://bishopfox.com",
Email: "[email protected]",
}
}
// withDefaults fills empty fields, so a caller may set only what it cares about.
func (p Placeholders) withDefaults() Placeholders {
d := DefaultPlaceholders()
if p.String == "" {
p.String = d.String
}
if p.Date == "" {
p.Date = d.Date
}
if p.URL == "" {
p.URL = d.URL
}
if p.Email == "" {
p.Email = d.Email
}
return p
}
+137
View File
@@ -0,0 +1,137 @@
package apispec
import "strings"
// destructiveWords are the verbs that suggest an operation changes something the
// operator would not want changed on a live target. Ported from sj, whose
// judgement here is sound: an API scanner that silently issues POST /purchase is
// a liability, and the cost of asking first is one click.
var destructiveWords = []string{
"add", "block", "build", "buy", "cancel", "change", "clear", "create", "delete",
"deploy", "destroy", "disable", "drop", "edit", "emergency", "erase", "execute",
"insert", "kill", "modify", "order", "overwrite", "pause", "purchase", "purge",
"rebuild", "remove", "replace", "reset", "restart", "revoke", "run", "sell",
"send", "set", "shutdown", "start", "stop", "terminate", "truncate", "update",
"upload", "wipe", "write",
}
// destructiveMethods are methods whose semantics are destructive regardless of
// what the path is called.
var destructiveMethods = map[string]string{
"DELETE": "DELETE removes the addressed resource",
"PATCH": "PATCH modifies the addressed resource",
}
// Classify reports whether an operation looks destructive, and why.
//
// It is advisory: it never blocks a render, only a send, and the caller decides
// whether to honor it. Two differences from sj matter.
//
// First, this runs at parse time over the method, path, operationId and summary.
// sj checks the interpolated URL at send time, reading u.RawPath + "?" +
// u.RawQuery — and RawPath is empty for any URL that needed no escaping, so on
// most targets the gate inspects only the query string and never fires at all.
//
// Second, sj drops DELETE and PATCH operations during parsing, so they are
// invisible. Reporting them instead means the operator can see the whole API
// surface and tick the one they meant to test.
func Classify(method, path, opID, summary string, whitelist []string) (bool, []string) {
var reasons []string
method = strings.ToUpper(method)
if reason, ok := destructiveMethods[method]; ok {
reasons = append(reasons, reason)
}
skip := make(map[string]bool, len(whitelist))
for _, w := range whitelist {
skip[strings.ToLower(strings.TrimSpace(w))] = true
}
haystack := splitWords(path + " " + opID + " " + summary)
for _, word := range destructiveWords {
if skip[word] {
continue
}
if containsWord(haystack, word) {
reasons = append(reasons, "mentions "+word)
}
}
return len(reasons) > 0, reasons
}
// containsWord matches a keyword against one end of an identifier, so "orders"
// and "reorder" both match "order" while "borders" does not. A plain substring
// test — which is what sj does — flags every path containing "border", "asset"
// (set) and similar, and an operator who learns the gate cries wolf will disable
// it.
//
// One boundary, not two: "orders" and "createOrder" are how this operation is
// ordinarily spelled, and both put a letter against one side. The cost is that a
// bare "border" reads as "order" — no boundary rule separates it from "reorder"
// — and the failures are not equal: a false positive costs a click, a false
// negative sends a live POST to a payment endpoint.
func containsWord(haystack, word string) bool {
for i := 0; ; {
idx := strings.Index(haystack[i:], word)
if idx < 0 {
return false
}
start := i + idx
end := start + len(word)
if !isWordByte(haystack, start-1) || !isWordByte(haystack, end) {
return true
}
i = start + 1
if i >= len(haystack) {
return false
}
}
}
// splitWords lowercases the haystack, breaking camelCase and separators apart.
//
// Without it "createOrder" is one token "createorder", where both keywords have
// a letter on each side and neither matches — and operationId is the field most
// likely to name the verb.
func splitWords(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z':
// Split at the "O" of createOrder, not inside the "URL" of parseURL.
if i > 0 && isLowerOrDigit(s[i-1]) {
b.WriteByte(' ')
}
b.WriteByte(c + 32)
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
b.WriteByte(c)
default:
b.WriteByte(' ')
}
}
return b.String()
}
func isLowerOrDigit(c byte) bool {
return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
}
// isWordByte reports whether the byte at i continues an identifier. Out-of-range
// counts as a boundary.
func isWordByte(s string, i int) bool {
if i < 0 || i >= len(s) {
return false
}
c := s[i]
return c >= 'a' && c <= 'z' || c >= '0' && c <= '9'
}
// DefaultMethods is the method whitelist a run starts with: everything except
// the two whose semantics are destructive on their own.
func DefaultMethods() []string {
return []string{"GET", "POST", "PUT", "HEAD", "OPTIONS", "TRACE"}
}
+312
View File
@@ -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
}
+75
View File
@@ -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",
}
+116
View File
@@ -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
}
+242
View File
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
+230
View File
@@ -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")
}
+188
View File
@@ -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) }
+797
View File
@@ -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
}
+297
View File
@@ -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")
}
+705
View File
@@ -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, "; ")
}
+336
View File
@@ -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
}
+347
View File
@@ -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[:])
}
+414
View File
@@ -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
}
+251
View File
@@ -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] + "..."
}
+210
View File
@@ -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
}
}
+209
View File
@@ -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)
}
+167
View File
@@ -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
}
+145
View File
@@ -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
}
+481
View File
@@ -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
}
+276
View File
@@ -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
}
+320
View File
@@ -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}
}
+121
View File
@@ -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()
}
}
+2 -2
View File
@@ -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)
}
}()
}
+8 -2
View File
@@ -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)
+1 -1
View File
@@ -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
}
+40
View File
@@ -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 {
+54 -17
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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 />} />
+1 -1
View File
@@ -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>
))}
+2 -2
View File
@@ -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>
)}
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
)
})}
+2 -2
View File
@@ -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>
+4 -4
View File
@@ -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>
)}
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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 ? (
+3 -3
View File
@@ -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={
+2 -2
View File
@@ -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>
{' '}&mdash; 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>
)}
+6
View File
@@ -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])
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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 &rarr; Project &rarr; Filtering, or
<strong>refused</strong>, by design - set an include rule in Settings &rarr; Project &rarr; 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&rsquo;s local API is open to local processes by design. Requests sent by automation go
- Joro&rsquo;s local API is open to local processes by design. Requests sent by automation go
through Joro&rsquo;s proxy and appear in History, where Match &amp; 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&rsquo;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&rsquo;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`}
+4 -4
View File
@@ -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&rsquo;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&rsquo;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&rsquo;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 &mdash; 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(', ')} &mdash; 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 &mdash; 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>
)}
+132
View File
@@ -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>
)
}
+317
View File
@@ -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>
)
}
+89
View File
@@ -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>
)
}
+501
View File
@@ -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>
)
}
+182
View File
@@ -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
}
}
+232
View File
@@ -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
+259
View File
@@ -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() {
+260
View File
@@ -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>
)
}
+320
View File
@@ -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}`
}
+547
View File
@@ -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>
)}
</>
)
}
+285
View File
@@ -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 &amp; 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>
)
}
+293
View File
@@ -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>
)
}
+615
View File
@@ -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] ?? '/'
}
+146
View File
@@ -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>
)
}
+306
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+229
View File
@@ -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">
+11 -11
View File
@@ -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&rsquo;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 &ldquo;no filter&rdquo;.
</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&rsquo;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&rsquo;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&rsquo;s own proxy, so they are never captured, scanned or
rewritten &mdash; 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
View File
@@ -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
View File
@@ -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 }>(
+11
View File
@@ -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))
+26
View File
@@ -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
}
}
+210
View File
@@ -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