mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Renamed the request argument ref to seq and suggested near-miss fields
This commit is contained in:
1 parent
6a58a1ec93
commit
418208b237
12 files changed
+326
-108
No files matched your search
@@ -16,7 +16,6 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
@@ -183,16 +182,13 @@ type Result struct {
|
||||
// DisallowUnknownFields is deliberate. The UI is a program that matches the server,
|
||||
// but an automation client is a language model that invents arguments. Silently
|
||||
// ignoring an invented "limit" would return an unbounded result set instead of the
|
||||
// error the agent needs in order to correct itself.
|
||||
// error the agent needs in order to correct itself. Rejecting is only half of that —
|
||||
// decodeArgs names the field the client probably meant, when one is close enough.
|
||||
func Typed[T any](fn func(ctx context.Context, p Principal, args T) (any, error)) Handler {
|
||||
return func(ctx context.Context, in Input) (any, error) {
|
||||
var args T
|
||||
if len(in.Args) > 0 && !bytes.Equal(bytes.TrimSpace(in.Args), []byte("null")) {
|
||||
dec := json.NewDecoder(bytes.NewReader(in.Args))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&args); err != nil {
|
||||
return nil, &Error{Code: CodeInvalidArgs, Msg: err.Error()}
|
||||
}
|
||||
args, err := decodeArgs[T](in.Args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fn(ctx, in.Principal, args)
|
||||
}
|
||||
@@ -203,13 +199,9 @@ func Typed[T any](fn func(ctx context.Context, p Principal, args T) (any, error)
|
||||
// don't parse have no target to check.
|
||||
func TypedTarget[T any](fn func(args T) (Target, error)) TargetExtractor {
|
||||
return func(raw json.RawMessage) (Target, error) {
|
||||
var args T
|
||||
if len(raw) > 0 && !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&args); err != nil {
|
||||
return Target{}, &Error{Code: CodeInvalidArgs, Msg: err.Error()}
|
||||
}
|
||||
args, err := decodeArgs[T](raw)
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
return fn(args)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// decodeArgs is the one place automation arguments become a Go value. Typed and
|
||||
// TypedTarget both route through it so the guidance on a misnamed field is identical
|
||||
// whether the client got it wrong in a handler's arguments or in the arguments the
|
||||
// scope guard reads.
|
||||
//
|
||||
// On error the returned value is partially populated; both callers return
|
||||
// immediately rather than inspect it.
|
||||
func decodeArgs[T any](raw json.RawMessage) (T, error) {
|
||||
var args T
|
||||
if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return args, nil
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&args); err != nil {
|
||||
return args, &Error{Code: CodeInvalidArgs, Msg: decodeMsg(err, reflect.TypeFor[T]())}
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// decodeMsg rewrites a decode failure into something a model can act on. Only the
|
||||
// unknown-field case is rewritten; every other failure, a type mismatch above all,
|
||||
// passes through untouched.
|
||||
//
|
||||
// A near miss earns a suggestion and nothing else does. The accepted names are not
|
||||
// enumerated on a miss, because every capability publishes a complete InputSchema
|
||||
// with additionalProperties:false — the caller already holds the list, so printing
|
||||
// it again costs tokens on every error and tells it nothing new.
|
||||
func decodeMsg(err error, t reflect.Type) string {
|
||||
field, ok := unknownField(err.Error())
|
||||
if !ok {
|
||||
return err.Error()
|
||||
}
|
||||
if best, ok := nearest(field, fieldCandidates(t, 0)); ok {
|
||||
return fmt.Sprintf("unknown field %q — did you mean %q?", field, best)
|
||||
}
|
||||
return fmt.Sprintf("unknown field %q", field)
|
||||
}
|
||||
|
||||
const unknownFieldPrefix = "json: unknown field "
|
||||
|
||||
// unknownField recovers the offending name from encoding/json's unknown-field error.
|
||||
// That error is a bare fmt.Errorf with no wrapping and no exported type, so matching
|
||||
// its text is the only handle there is. The text is identical in the v1 decoder and
|
||||
// in the v2 compatibility shim, both of which format it with %q, so this holds
|
||||
// whether or not GOEXPERIMENT=jsonv2 is set.
|
||||
//
|
||||
// strconv.Unquote is the exact inverse of that %q, and it fails on trailing content,
|
||||
// so a longer message that merely starts the same way cannot match by accident.
|
||||
func unknownField(msg string) (string, bool) {
|
||||
q, ok := strings.CutPrefix(msg, unknownFieldPrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
name, err := strconv.Unquote(q)
|
||||
if err != nil || name == "" {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
// maxAliasDepth bounds the struct walk. Reflect types cycle only through pointers,
|
||||
// which the walk does not follow past a struct boundary, so a depth cap terminates
|
||||
// where a visited set would and costs nothing to read.
|
||||
const maxAliasDepth = 3
|
||||
|
||||
// candidate is one name the client's field is measured against, and the name to
|
||||
// suggest when it wins. The two differ only for an alias, where a former name is
|
||||
// matched but the field that replaced it is what gets named.
|
||||
type candidate struct{ match, suggest string }
|
||||
|
||||
// fieldCandidates lists the names a decode of t will accept, by the same rules
|
||||
// encoding/json applies: the tag name when tagged, the Go field name otherwise,
|
||||
// `json:"-"` skipped, anonymous structs flattened. An `alias` tag adds a name that
|
||||
// is matched but not accepted — see the alias rule on nearest.
|
||||
//
|
||||
// The anonymous check must precede the exported check. An embedded field of an
|
||||
// unexported type reads as unexported through IsExported, yet the decoder still
|
||||
// promotes its exported fields; scriptReplaceArgs in internal/capreg is exactly that
|
||||
// shape, and reversing the two would silently empty its candidate list.
|
||||
func fieldCandidates(t reflect.Type, depth int) []candidate {
|
||||
for t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() != reflect.Struct || depth > maxAliasDepth {
|
||||
return nil
|
||||
}
|
||||
var out []candidate
|
||||
add := func(c candidate) {
|
||||
// Deduping is load-bearing: a name promoted twice would rank as its own
|
||||
// second best and trip the tie guard in nearest.
|
||||
if c.match == "" || slices.ContainsFunc(out, func(o candidate) bool { return o.match == c.match }) {
|
||||
return
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
for i := range t.NumField() {
|
||||
f := t.Field(i)
|
||||
tag := f.Tag.Get("json")
|
||||
// The whole tag, not the parsed name: `json:"-,"` legitimately names a field "-".
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if f.Anonymous && name == "" {
|
||||
for _, c := range fieldCandidates(f.Type, depth+1) {
|
||||
add(c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
add(candidate{match: name, suggest: name})
|
||||
add(candidate{match: f.Tag.Get("alias"), suggest: name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nearest names the one field closest to what the client sent, or reports that
|
||||
// nothing is close enough.
|
||||
//
|
||||
// The distance is computed after folding case and dropping separators, so a client
|
||||
// writing max_matches or MaxMatches lands on maxMatches at distance zero. The case
|
||||
// half of that is belt and braces — DisallowUnknownFields already matches names
|
||||
// case-insensitively, so contenttype never reaches here — and the separator half is
|
||||
// the part doing real work.
|
||||
//
|
||||
// An alias resolves to the field it is declared on, so a name that is no longer
|
||||
// accepted can still point at the one that replaced it. This is the only way a
|
||||
// suggestion crosses a semantic gap: string distance cannot, and a threshold loosened
|
||||
// far enough to bridge one would suggest a match for nearly every short key. Declare
|
||||
// an alias rather than widening the limit.
|
||||
func nearest(unknown string, candidates []candidate) (string, bool) {
|
||||
want := normalizeName(unknown)
|
||||
// Scaled to the input's length: one edit is most of a short word and very little
|
||||
// of a long one. Two characters or fewer admit exact matches only, which is what
|
||||
// keeps a stray single-character key from drawing a coin flip between http.diff's
|
||||
// "a" and "b".
|
||||
limit := 2
|
||||
switch {
|
||||
case len(want) <= 2:
|
||||
limit = 0
|
||||
case len(want) <= 5:
|
||||
limit = 1
|
||||
}
|
||||
|
||||
best, second, bestD, secondD := "", "", limit+1, limit+1
|
||||
for _, c := range candidates {
|
||||
d := editDistance(want, normalizeName(c.match))
|
||||
switch {
|
||||
case d < bestD:
|
||||
best, second, secondD, bestD = c.suggest, best, bestD, d
|
||||
case d < secondD:
|
||||
second, secondD = c.suggest, d
|
||||
}
|
||||
}
|
||||
// A tie is two equally good answers, which is no answer: saying nothing beats
|
||||
// sending a model confidently to the wrong field. Two names that resolve to the
|
||||
// same field — a field and its own alias — are one answer, not a tie.
|
||||
if bestD > limit || (bestD == secondD && best != second) {
|
||||
return "", false
|
||||
}
|
||||
return best, true
|
||||
}
|
||||
|
||||
// normalizeName folds a field name to the form the distance is measured on: ASCII
|
||||
// lowercase with the separators that distinguish naming conventions removed.
|
||||
func normalizeName(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '_', '-', '.', ' ':
|
||||
continue
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
r += 'a' - 'A'
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// editDistance is Levenshtein over two rows.
|
||||
func editDistance(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return len([]rune(b))
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
sub := prev[j-1]
|
||||
if ra[i-1] != rb[j-1] {
|
||||
sub++
|
||||
}
|
||||
curr[j] = min(sub, prev[j]+1, curr[j-1]+1)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const (
|
||||
)
|
||||
|
||||
type fuzzStartArgs struct {
|
||||
Ref int `json:"ref"`
|
||||
Seq int `json:"seq" alias:"ref"`
|
||||
Edits []httptools.Edit `json:"edits"`
|
||||
Wordlist []string `json:"wordlist"`
|
||||
Scheme string `json:"scheme"`
|
||||
@@ -65,7 +65,7 @@ func registerFuzzer(r *capability.Registry, d Deps) {
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"ref": {"type":"integer","description":"Seq of the captured request to use as the template."},
|
||||
"seq": {"type":"integer","description":"The captured request to use as the template."},
|
||||
"edits": {"type":"array","items":` + editSchema + `,"description":"Edits that must introduce the marker FUZZ, e.g. {\"op\":\"setQuery\",\"name\":\"id\",\"value\":\"FUZZ\"}."},
|
||||
"wordlist": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":500,"description":"Payloads substituted for FUZZ, one request each. Max 500 entries, 64 KB total."},
|
||||
"scheme": {"type":"string","enum":["http","https"],"description":"Override the scheme; defaults to the captured request's."},
|
||||
@@ -73,14 +73,14 @@ func registerFuzzer(r *capability.Registry, d Deps) {
|
||||
"threads": {"type":"integer","minimum":1,"maximum":10,"description":"Concurrent requests; default 4."},
|
||||
"ratePerSec": {"type":"number","minimum":0,"maximum":50,"description":"Requests per second across all threads; 0 means unlimited."}
|
||||
},
|
||||
"required":["ref","wordlist"],
|
||||
"required":["seq","wordlist"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"ref":1204,"edits":[{"op":"setPath","value":"/admin/FUZZ"}],` +
|
||||
ArgsExample: json.RawMessage(`{"seq":1204,"edits":[{"op":"setPath","value":"/admin/FUZZ"}],` +
|
||||
`"wordlist":["users","config","backup"],"ratePerSec":10}`),
|
||||
MaxOutputBytes: 8 << 10,
|
||||
Target: capability.TypedTarget(func(args fuzzStartArgs) (capability.Target, error) {
|
||||
return resolveTarget(d, args.Ref, args.Scheme, args.Host, args.Edits)
|
||||
return resolveTarget(d, args.Seq, args.Scheme, args.Host, args.Edits)
|
||||
}),
|
||||
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args fuzzStartArgs) (any, error) {
|
||||
if d.Fuzzer == nil || d.Transport == nil {
|
||||
@@ -89,12 +89,12 @@ func registerFuzzer(r *capability.Registry, d Deps) {
|
||||
if d.Store == nil {
|
||||
return nil, fmt.Errorf("capture store is unavailable")
|
||||
}
|
||||
item := d.Store.GetBySeq(args.Ref)
|
||||
item := d.Store.GetBySeq(args.Seq)
|
||||
if item == nil {
|
||||
return nil, fmt.Errorf("no captured request with seq %d", args.Ref)
|
||||
return nil, fmt.Errorf("no captured request with seq %d", args.Seq)
|
||||
}
|
||||
if len(item.ReqRaw) == 0 {
|
||||
return nil, fmt.Errorf("request %d has no captured bytes to fuzz", args.Ref)
|
||||
return nil, fmt.Errorf("request %d has no captured bytes to fuzz", args.Seq)
|
||||
}
|
||||
|
||||
words, err := checkWordlist(args.Wordlist)
|
||||
@@ -113,7 +113,7 @@ func registerFuzzer(r *capability.Registry, d Deps) {
|
||||
`{"op":"replaceInBody","find":"8291","value":"FUZZ"}`)
|
||||
}
|
||||
|
||||
scheme, host, _, _, err := httptools.TargetOf(d.Store, args.Ref, args.Scheme, args.Host, args.Edits)
|
||||
scheme, host, _, _, err := httptools.TargetOf(d.Store, args.Seq, args.Scheme, args.Host, args.Edits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ var highlightColors = []string{
|
||||
}
|
||||
|
||||
type highlightArgs struct {
|
||||
Ref int `json:"ref"`
|
||||
Seq int `json:"seq" alias:"ref"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ func registerHighlight(r *capability.Registry, d Deps) {
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"ref": {"type":"integer","minimum":1,"description":"Request seq, as returned by history_list."},
|
||||
"seq": {"type":"integer","minimum":1,"description":"The captured request, as returned by history_list."},
|
||||
"color": {"type":"string","enum":["red","orange","yellow","green","cyan","blue","purple","pink","gray",""],"description":"Highlight colour. An empty string clears the highlight."}
|
||||
},
|
||||
"required":["ref","color"],
|
||||
"required":["seq","color"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"ref":1842,"color":"red"}`),
|
||||
ArgsExample: json.RawMessage(`{"seq":1842,"color":"red"}`),
|
||||
MaxOutputBytes: 4 << 10,
|
||||
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args highlightArgs) (any, error) {
|
||||
if d.Store == nil || d.SetHighlight == nil {
|
||||
@@ -52,18 +52,18 @@ func registerHighlight(r *capability.Registry, d Deps) {
|
||||
return nil, fmt.Errorf("colour %q is not one of %s, or \"\" to clear",
|
||||
args.Color, strings.Join(highlightColors, ", "))
|
||||
}
|
||||
item := d.Store.GetBySeq(args.Ref)
|
||||
item := d.Store.GetBySeq(args.Seq)
|
||||
if item == nil {
|
||||
return nil, fmt.Errorf("no captured request with seq %d", args.Ref)
|
||||
return nil, fmt.Errorf("no captured request with seq %d", args.Seq)
|
||||
}
|
||||
d.SetHighlight(item.ID, color)
|
||||
|
||||
if color == "" {
|
||||
capability.RecordChange(ctx, "clear highlight on seq %d (%s)", args.Ref, item.Host)
|
||||
return fmt.Sprintf("cleared highlight on seq %d", args.Ref), nil
|
||||
capability.RecordChange(ctx, "clear highlight on seq %d (%s)", args.Seq, item.Host)
|
||||
return fmt.Sprintf("cleared highlight on seq %d", args.Seq), nil
|
||||
}
|
||||
capability.RecordChange(ctx, "highlight seq %d %s (%s)", args.Ref, color, item.Host)
|
||||
return fmt.Sprintf("highlighted seq %d %s", args.Ref, color), nil
|
||||
capability.RecordChange(ctx, "highlight seq %d %s (%s)", args.Seq, color, item.Host)
|
||||
return fmt.Sprintf("highlighted seq %d %s", args.Seq, color), nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -54,19 +54,19 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"ref": {"type":"integer","description":"A captured request's seq, as shown by history_list."},
|
||||
"refs": {"type":"array","items":{"type":"integer"},"maxItems":50,"description":"Several seqs, rendered as a comparison table. Max 50."},
|
||||
"seq": {"type":"integer","description":"A captured request, as shown by history_list."},
|
||||
"seqs": {"type":"array","items":{"type":"integer"},"maxItems":50,"description":"Several captured requests, rendered as a comparison table. Max 50."},
|
||||
"fields": {"type":"string","description":"\"+fullhash\" to include the full SHA-256 rather than its 8-hex prefix."}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"refs":[1204,1209,1213]}`),
|
||||
ArgsExample: json.RawMessage(`{"seqs":[1204,1209,1213]}`),
|
||||
MaxOutputBytes: 64 << 10,
|
||||
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, args httptools.FingerprintArgs) (any, error) {
|
||||
if d.Store == nil {
|
||||
return nil, fmt.Errorf("capture store is unavailable")
|
||||
}
|
||||
return httptools.FingerprintRefs(d.Store, args)
|
||||
return httptools.FingerprintSeqs(d.Store, args)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -84,13 +84,13 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
"the bytes returned is named, and only that. " +
|
||||
"As a tool this renders one metadata line, then the bytes, then any notes: the bytes begin after " +
|
||||
"the first newline and nothing is ever inserted ahead of them. From the SDK it is an object — " +
|
||||
"{ref, part, section, encoding, totalLength, offset, returned, truncated, text, redacted[], " +
|
||||
"{seq, part, section, encoding, totalLength, offset, returned, truncated, text, redacted[], " +
|
||||
"decoded} — where text is exactly the bytes, with no metadata and no notes, so a script reads " +
|
||||
"fields instead of parsing.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"ref": {"type":"integer","description":"A captured request's seq."},
|
||||
"seq": {"type":"integer","description":"A captured request, as shown by history_list."},
|
||||
"part": {"type":"string","enum":["req","resp"],"description":"Which half to read; default resp."},
|
||||
"section": {"type":"string","enum":["headers","body","raw"],"description":"Which section; default body. \"raw\" is the whole dump with no decoding."},
|
||||
"offset": {"type":"integer","description":"Byte offset within the section. Negative reads from the end."},
|
||||
@@ -98,18 +98,18 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
"decode": {"type":"boolean","description":"Decompress gzip/deflate before slicing; default true. Forced off for section \"raw\"."},
|
||||
"encoding": {"type":"string","enum":["auto","text","hex","base64"],"description":"How to render the bytes; default auto."}
|
||||
},
|
||||
"required":["ref"],
|
||||
"required":["seq"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"ref":1204,"offset":-2048}`),
|
||||
ArgsExample: json.RawMessage(`{"seq":1204,"offset":-2048}`),
|
||||
MaxOutputBytes: 128 << 10,
|
||||
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args httptools.ReadArgs) (any, error) {
|
||||
if d.Store == nil {
|
||||
return nil, fmt.Errorf("capture store is unavailable")
|
||||
}
|
||||
item := d.Store.GetBySeq(args.Ref)
|
||||
item := d.Store.GetBySeq(args.Seq)
|
||||
if item == nil {
|
||||
return nil, fmt.Errorf("no captured request with seq %d", args.Ref)
|
||||
return nil, fmt.Errorf("no captured request with seq %d", args.Seq)
|
||||
}
|
||||
return httptools.ReadRange(item.ReqRaw, item.RespRaw, args, !p.AllowCredentials)
|
||||
}),
|
||||
@@ -120,7 +120,7 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
Class: capability.ClassHTTP,
|
||||
Title: "Search captured traffic",
|
||||
Description: "Find a string or regex across captured traffic and return match offsets with surrounding " +
|
||||
"context, without returning any bodies. With ref set it searches one request; without it, it " +
|
||||
"context, without returning any bodies. With seq set it searches one request; without it, it " +
|
||||
"searches a filtered corpus. Matching is against the bytes as captured, so a compressed response " +
|
||||
"will not match a plaintext pattern unless deep is set. Matching is case-insensitive by default. " +
|
||||
"Unless this token has credential visibility, sensitive header values are masked before matching, " +
|
||||
@@ -130,7 +130,7 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
"properties":{
|
||||
"pattern": {"type":"string","description":"The string or RE2 pattern to find."},
|
||||
"regex": {"type":"boolean","description":"Treat pattern as a regular expression."},
|
||||
"ref": {"type":"integer","description":"Search only this captured request. Omit to search the corpus."},
|
||||
"seq": {"type":"integer","description":"Search only this captured request, as shown by history_list. Omit to search the corpus."},
|
||||
"part": {"type":"string","enum":["req","resp","both"],"description":"Which half to search; default resp."},` +
|
||||
historyFilterProps + `,
|
||||
"maxRequests": {"type":"integer","minimum":1,"maximum":200,"description":"Corpus mode: requests to scan; default 50."},
|
||||
@@ -218,7 +218,7 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"ref": {"type":"integer","description":"Seq of the captured request to base this on."},
|
||||
"seq": {"type":"integer","description":"The captured request to base this on."},
|
||||
"edits": {"type":"array","items":` + editSchema + `,"description":"Structural edits, applied in order."},
|
||||
"scheme": {"type":"string","enum":["http","https"],"description":"Override the scheme; defaults to the captured request's."},
|
||||
"host": {"type":"string","description":"Override the host; defaults to the captured request's. Must pass this token's scope and host whitelist."},
|
||||
@@ -226,14 +226,14 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
"timeoutMs": {"type":"integer","minimum":1000,"maximum":60000,"description":"Per-request timeout; default 15000."},
|
||||
"useContext": {"type":"boolean","description":"Apply and record this token's session cookies; default true. Set false to send exactly what you specified, e.g. when testing unauthenticated access."}
|
||||
},
|
||||
"required":["ref"],
|
||||
"required":["seq"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"ref":1204,"edits":[{"op":"setHeader","name":"X-Forwarded-For","value":"127.0.0.1"}]}`),
|
||||
ArgsExample: json.RawMessage(`{"seq":1204,"edits":[{"op":"setHeader","name":"X-Forwarded-For","value":"127.0.0.1"}]}`),
|
||||
MaxOutputBytes: 32 << 10,
|
||||
Timeout: 70 * time.Second, // over the max per-request timeout, so the tool times out first
|
||||
Target: capability.TypedTarget(func(args httptools.ResendArgs) (capability.Target, error) {
|
||||
return resolveTarget(d, args.Ref, args.Scheme, args.Host, args.Edits)
|
||||
return resolveTarget(d, args.Seq, args.Scheme, args.Host, args.Edits)
|
||||
}),
|
||||
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args httptools.ResendArgs) (any, error) {
|
||||
if d.Store == nil {
|
||||
@@ -255,7 +255,7 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"ref": {"type":"integer","description":"Seq of the captured request to base every variant on."},
|
||||
"seq": {"type":"integer","description":"The captured request to base every variant on."},
|
||||
"variants": {
|
||||
"type":"array","minItems":1,"maxItems":50,
|
||||
"items":{
|
||||
@@ -276,15 +276,15 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
"totalBudgetMs": {"type":"integer","minimum":1000,"maximum":120000,"description":"Wall-clock budget for the whole batch; default 60000."},
|
||||
"useContext": {"type":"boolean","description":"Apply this token's session cookies to every variant; default true. Set-Cookie from a batch is never recorded, so all variants start from the same session."}
|
||||
},
|
||||
"required":["ref","variants"],
|
||||
"required":["seq","variants"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"ref":1204,"variants":[{"label":"baseline"},` +
|
||||
ArgsExample: json.RawMessage(`{"seq":1204,"variants":[{"label":"baseline"},` +
|
||||
`{"label":"xff-lo","edits":[{"op":"setHeader","name":"X-Forwarded-For","value":"127.0.0.1"}]}]}`),
|
||||
MaxOutputBytes: 64 << 10,
|
||||
Timeout: 130 * time.Second, // over the max total budget, so the tool times out first
|
||||
Target: capability.TypedTarget(func(args httptools.BatchArgs) (capability.Target, error) {
|
||||
return resolveTarget(d, args.Ref, args.Scheme, args.Host, nil)
|
||||
return resolveTarget(d, args.Seq, args.Scheme, args.Host, nil)
|
||||
}),
|
||||
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args httptools.BatchArgs) (any, error) {
|
||||
if d.Store == nil {
|
||||
@@ -306,11 +306,11 @@ func registerHTTP(r *capability.Registry, d Deps) {
|
||||
// arguments. Path edits within a batch are not individually guarded, which is a
|
||||
// gap worth knowing about — it is bounded by the host check, which is the control
|
||||
// that matters for staying inside an engagement.
|
||||
func resolveTarget(d Deps, ref int, scheme, host string, edits []httptools.Edit) (capability.Target, error) {
|
||||
func resolveTarget(d Deps, seq int, scheme, host string, edits []httptools.Edit) (capability.Target, error) {
|
||||
if d.Store == nil {
|
||||
return capability.Target{}, fmt.Errorf("capture store is unavailable")
|
||||
}
|
||||
_, h, method, path, err := httptools.TargetOf(d.Store, ref, scheme, host, edits)
|
||||
_, h, method, path, err := httptools.TargetOf(d.Store, seq, scheme, host, edits)
|
||||
if err != nil {
|
||||
return capability.Target{}, err
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ func registerScript(r *capability.Registry, d Deps) {
|
||||
"required":["source"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"source":"async function run(ctx) {\n const base = await joro.http.fingerprint({ ref: ctx.input.ref });\n return { base: base };\n}","input":{"ref":1842}}`),
|
||||
ArgsExample: json.RawMessage(`{"source":"async function run(ctx) {\n const base = await joro.http.fingerprint({ seq: ctx.input.seq });\n return { base: base };\n}","input":{"seq":1842}}`),
|
||||
|
||||
MaxOutputBytes: ScriptRunOutputCap,
|
||||
Timeout: scriptRunTimeout,
|
||||
@@ -335,7 +335,7 @@ func registerScript(r *capability.Registry, d Deps) {
|
||||
"required":["id","source"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"id":"idor-sweep","name":"IDOR sweep","version":"0.1.0","description":"Resends a request with neighbouring object ids and reports which ones answer 200.","source":"async function run(ctx) {\n return { checked: ctx.input.ref };\n}","triggers":["manual"]}`),
|
||||
ArgsExample: json.RawMessage(`{"id":"idor-sweep","name":"IDOR sweep","version":"0.1.0","description":"Resends a request with neighbouring object ids and reports which ones answer 200.","source":"async function run(ctx) {\n return { checked: ctx.input.seq };\n}","triggers":["manual"]}`),
|
||||
|
||||
MaxOutputBytes: 4 << 10,
|
||||
|
||||
@@ -405,7 +405,7 @@ func registerScript(r *capability.Registry, d Deps) {
|
||||
"required":["id","source","expectedHash"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"id":"idor-sweep","version":"0.2.0","expectedHash":"9f2b1c0d5e6a7b8c","source":"async function run(ctx) {\n return { checked: ctx.input.ref };\n}"}`),
|
||||
ArgsExample: json.RawMessage(`{"id":"idor-sweep","version":"0.2.0","expectedHash":"9f2b1c0d5e6a7b8c","source":"async function run(ctx) {\n return { checked: ctx.input.seq };\n}"}`),
|
||||
|
||||
MaxOutputBytes: 4 << 10,
|
||||
|
||||
@@ -472,7 +472,7 @@ func registerScript(r *capability.Registry, d Deps) {
|
||||
"required":["id"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"id":"idor-check","input":{"ref":1842}}`),
|
||||
ArgsExample: json.RawMessage(`{"id":"idor-check","input":{"seq":1842}}`),
|
||||
|
||||
MaxOutputBytes: ScriptRunOutputCap,
|
||||
Timeout: scriptRunTimeout,
|
||||
|
||||
@@ -40,7 +40,7 @@ type BatchVariant struct {
|
||||
|
||||
// BatchArgs is the argument shape of http.batch.
|
||||
type BatchArgs struct {
|
||||
Ref int `json:"ref"`
|
||||
Seq int `json:"seq" alias:"ref"`
|
||||
Variants []BatchVariant `json:"variants"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
@@ -69,9 +69,9 @@ type batchRow struct {
|
||||
// behavior change to the fuzzer. Duplicating the shape is the repo's existing
|
||||
// answer to this (h2_mitm.go mirrors replace.go the same way).
|
||||
func Batch(ctx context.Context, d ResendDeps, args BatchArgs) (string, error) {
|
||||
item := d.Store.GetBySeq(args.Ref)
|
||||
item := d.Store.GetBySeq(args.Seq)
|
||||
if item == nil {
|
||||
return "", fmt.Errorf("no captured request with seq %d", args.Ref)
|
||||
return "", fmt.Errorf("no captured request with seq %d", args.Seq)
|
||||
}
|
||||
if len(args.Variants) == 0 {
|
||||
return "", fmt.Errorf("variants is required")
|
||||
@@ -103,7 +103,7 @@ func Batch(ctx context.Context, d ResendDeps, args BatchArgs) (string, error) {
|
||||
limiter = tick.C
|
||||
}
|
||||
|
||||
scheme, host, _, _, err := TargetOf(d.Store, args.Ref, args.Scheme, args.Host, nil)
|
||||
scheme, host, _, _, err := TargetOf(d.Store, args.Seq, args.Scheme, args.Host, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -208,8 +208,8 @@ func renderBatch(args BatchArgs, rows []batchRow, elapsed time.Duration) string
|
||||
|
||||
t := newTable("seq", "label", "status", "len", "ms", "shash", "words", "lines", "note")
|
||||
t.empty = "(no results)"
|
||||
t.note(fmt.Sprintf("batch ref=%d n=%d ok=%d err=%d elapsed=%.1fs base: shash=%s",
|
||||
args.Ref, len(rows), ok, errs, elapsed.Seconds(), dash(modal)))
|
||||
t.note(fmt.Sprintf("batch seq=%d n=%d ok=%d err=%d elapsed=%.1fs base: shash=%s",
|
||||
args.Seq, len(rows), ok, errs, elapsed.Seconds(), dash(modal)))
|
||||
|
||||
var outliers []string
|
||||
for _, r := range rows {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const (
|
||||
DefaultHistoryLimit = 50
|
||||
MaxHistoryLimit = 200
|
||||
MaxFingerprintRefs = 50
|
||||
MaxFingerprintSeqs = 50
|
||||
)
|
||||
|
||||
// HistoryArgs maps one-to-one onto proxy.RequestFilter, so this tool adds no
|
||||
@@ -280,37 +280,37 @@ func HistoryStats(store *proxy.Store, scope *proxy.Scope, args HistoryArgs) stri
|
||||
|
||||
// FingerprintArgs is the argument shape of http.fingerprint.
|
||||
type FingerprintArgs struct {
|
||||
Ref int `json:"ref"`
|
||||
Refs []int `json:"refs"`
|
||||
Seq int `json:"seq" alias:"ref"`
|
||||
Seqs []int `json:"seqs" alias:"refs"`
|
||||
Fields string `json:"fields"` // "+fullhash"
|
||||
}
|
||||
|
||||
// FingerprintRefs computes fingerprints for one or more captured responses.
|
||||
func FingerprintRefs(store *proxy.Store, args FingerprintArgs) (string, error) {
|
||||
refs := args.Refs
|
||||
if args.Ref > 0 {
|
||||
refs = append([]int{args.Ref}, refs...)
|
||||
// FingerprintSeqs computes fingerprints for one or more captured responses.
|
||||
func FingerprintSeqs(store *proxy.Store, args FingerprintArgs) (string, error) {
|
||||
seqs := args.Seqs
|
||||
if args.Seq > 0 {
|
||||
seqs = append([]int{args.Seq}, seqs...)
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return "", fmt.Errorf("ref or refs is required")
|
||||
if len(seqs) == 0 {
|
||||
return "", fmt.Errorf("seq or seqs is required")
|
||||
}
|
||||
if len(refs) > MaxFingerprintRefs {
|
||||
return "", fmt.Errorf("%d refs exceeds the %d-ref limit; call again with a smaller set", len(refs), MaxFingerprintRefs)
|
||||
if len(seqs) > MaxFingerprintSeqs {
|
||||
return "", fmt.Errorf("%d seqs exceeds the %d-seq limit; call again with a smaller set", len(seqs), MaxFingerprintSeqs)
|
||||
}
|
||||
wantFull := strings.Contains(args.Fields, "+fullhash")
|
||||
|
||||
fps := make([]Fingerprint, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
item := store.GetBySeq(ref)
|
||||
fps := make([]Fingerprint, 0, len(seqs))
|
||||
for _, seq := range seqs {
|
||||
item := store.GetBySeq(seq)
|
||||
if item == nil {
|
||||
fps = append(fps, Fingerprint{Seq: ref, Err: "no captured request with this seq"})
|
||||
fps = append(fps, Fingerprint{Seq: seq, Err: "no captured request with this seq"})
|
||||
continue
|
||||
}
|
||||
if len(item.RespRaw) == 0 {
|
||||
fps = append(fps, Fingerprint{Seq: ref, Err: "no response was captured"})
|
||||
fps = append(fps, Fingerprint{Seq: seq, Err: "no response was captured"})
|
||||
continue
|
||||
}
|
||||
fps = append(fps, fingerprintResponse(ref, item.RespRaw, item.Duration.Milliseconds(), wantFull))
|
||||
fps = append(fps, fingerprintResponse(seq, item.RespRaw, item.Duration.Milliseconds(), wantFull))
|
||||
}
|
||||
return renderFingerprints(fps, wantFull), nil
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
|
||||
// ReadArgs is the argument shape of http.read.
|
||||
type ReadArgs struct {
|
||||
Ref int `json:"ref"`
|
||||
Seq int `json:"seq" alias:"ref"`
|
||||
Part string `json:"part"` // req | resp
|
||||
Section string `json:"section"` // headers | body | raw
|
||||
Offset int `json:"offset"` // negative reads from the end
|
||||
@@ -35,7 +35,7 @@ type ReadArgs struct {
|
||||
// The two forms are never both sent — text may be 16 KB, and duplicating it as
|
||||
// structuredContent would double the cost of every read.
|
||||
type ReadResult struct {
|
||||
Ref int `json:"ref"`
|
||||
Seq int `json:"seq"`
|
||||
Part string `json:"part"`
|
||||
Section string `json:"section"`
|
||||
Encoding string `json:"encoding"`
|
||||
@@ -81,7 +81,7 @@ func ReadRange(reqRaw, respRaw []byte, args ReadArgs, maskCredentials bool) (*Re
|
||||
return nil, fmt.Errorf(`part must be "req" or "resp", got %q`, args.Part)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("no %s bytes were captured for request %d", part, args.Ref)
|
||||
return nil, fmt.Errorf("no %s bytes were captured for request %d", part, args.Seq)
|
||||
}
|
||||
|
||||
var spans []maskSpan
|
||||
@@ -129,7 +129,7 @@ func ReadRange(reqRaw, respRaw []byte, args ReadArgs, maskCredentials bool) (*Re
|
||||
window := sel[start:end]
|
||||
|
||||
res := &ReadResult{
|
||||
Ref: args.Ref,
|
||||
Seq: args.Seq,
|
||||
Part: part,
|
||||
Section: section,
|
||||
TotalLength: total,
|
||||
@@ -188,8 +188,8 @@ func encodeWindow(window []byte, want string, baseOffset int) (encoding, text st
|
||||
// credential it will report as absent.
|
||||
func (r *ReadResult) Render() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "ref=%d part=%s section=%s enc=%s total=%d off=%d ret=%d truncated=%t",
|
||||
r.Ref, r.Part, r.Section, r.Encoding, r.TotalLength, r.Offset, r.Returned, r.Truncated)
|
||||
fmt.Fprintf(&b, "seq=%d part=%s section=%s enc=%s total=%d off=%d ret=%d truncated=%t",
|
||||
r.Seq, r.Part, r.Section, r.Encoding, r.TotalLength, r.Offset, r.Returned, r.Truncated)
|
||||
if r.Decoded != "" {
|
||||
fmt.Fprintf(&b, " decoded=%s", r.Decoded)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
// a redirect by reading the Location from the result and issuing a second,
|
||||
// separately guarded call.
|
||||
type ResendArgs struct {
|
||||
Ref int `json:"ref"`
|
||||
Seq int `json:"seq" alias:"ref"`
|
||||
Edits []Edit `json:"edits"`
|
||||
Scheme string `json:"scheme"`
|
||||
Host string `json:"host"`
|
||||
@@ -49,10 +49,10 @@ type ResendDeps struct {
|
||||
// the same destination the send will actually use. It reads the capture store,
|
||||
// which is why explicit host arguments and the captured URL have to agree on
|
||||
// precedence here and in Resend.
|
||||
func TargetOf(store *proxy.Store, ref int, scheme, host string, edits []Edit) (dialScheme, dialHost, method, path string, err error) {
|
||||
item := store.GetBySeq(ref)
|
||||
func TargetOf(store *proxy.Store, seq int, scheme, host string, edits []Edit) (dialScheme, dialHost, method, path string, err error) {
|
||||
item := store.GetBySeq(seq)
|
||||
if item == nil {
|
||||
return "", "", "", "", fmt.Errorf("no captured request with seq %d", ref)
|
||||
return "", "", "", "", fmt.Errorf("no captured request with seq %d", seq)
|
||||
}
|
||||
capScheme, capHost := hostFromCapture(item.URL)
|
||||
dialScheme = firstNonEmpty(strings.ToLower(scheme), capScheme, "https")
|
||||
@@ -88,12 +88,12 @@ func TargetOf(store *proxy.Store, ref int, scheme, host string, edits []Edit) (d
|
||||
// Resend applies structural edits to a captured request and sends it through
|
||||
// Joro's proxy, returning a fingerprint rather than a body.
|
||||
func Resend(ctx context.Context, d ResendDeps, args ResendArgs) (string, error) {
|
||||
item := d.Store.GetBySeq(args.Ref)
|
||||
item := d.Store.GetBySeq(args.Seq)
|
||||
if item == nil {
|
||||
return "", fmt.Errorf("no captured request with seq %d", args.Ref)
|
||||
return "", fmt.Errorf("no captured request with seq %d", args.Seq)
|
||||
}
|
||||
if len(item.ReqRaw) == 0 {
|
||||
return "", fmt.Errorf("request %d has no captured bytes to resend", args.Ref)
|
||||
return "", fmt.Errorf("request %d has no captured bytes to resend", args.Seq)
|
||||
}
|
||||
|
||||
raw, err := ApplyEdits(item.ReqRaw, args.Edits)
|
||||
@@ -104,7 +104,7 @@ func Resend(ctx context.Context, d ResendDeps, args ResendArgs) (string, error)
|
||||
raw = proxy.UpdateContentLength(raw)
|
||||
}
|
||||
|
||||
scheme, host, _, _, err := TargetOf(d.Store, args.Ref, args.Scheme, args.Host, args.Edits)
|
||||
scheme, host, _, _, err := TargetOf(d.Store, args.Seq, args.Scheme, args.Host, args.Edits)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func renderResend(args ResendArgs, res *ProxySendResult, fp Fingerprint, supplie
|
||||
if res.Seq == 0 {
|
||||
seqLabel = "seq -"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s <- ref %d (%d edits) %s %s\n", seqLabel, args.Ref, len(args.Edits), res.Method, res.URL)
|
||||
fmt.Fprintf(&b, "%s <- from %d (%d edits) %s %s\n", seqLabel, args.Seq, len(args.Edits), res.Method, res.URL)
|
||||
fmt.Fprintf(&b, "status=%d len=%d ms=%d ct=%s bhash=%s shash=%s words=%d lines=%d\n",
|
||||
fp.Status, fp.Len, fp.DurationMs, fp.CT, fp.BodyHash, fp.StructHash, fp.Words, fp.Lines)
|
||||
if fp.Note != "" || fp.Server != "" || fp.Decoded != "" {
|
||||
@@ -181,7 +181,7 @@ func renderResend(args ResendArgs, res *ProxySendResult, fp Fingerprint, supplie
|
||||
// A breadcrumb: the exact follow-up calls with the handle already filled in.
|
||||
// Models follow these; without them they guess at argument shapes and spend a
|
||||
// call finding out.
|
||||
fmt.Fprintf(&b, "read: http_read{ref:%d} diff: http_diff{a:%d,b:%d}", res.Seq, args.Ref, res.Seq)
|
||||
fmt.Fprintf(&b, "read: http_read{seq:%d} diff: http_diff{a:%d,b:%d}", res.Seq, args.Seq, res.Seq)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ const (
|
||||
MaxSearchMaxRequests = 200
|
||||
)
|
||||
|
||||
// SearchArgs is the argument shape of http.search. The absence of Ref selects
|
||||
// SearchArgs is the argument shape of http.search. The absence of Seq selects
|
||||
// corpus mode; its presence selects single-response mode.
|
||||
type SearchArgs struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Regex bool `json:"regex"`
|
||||
Ref int `json:"ref"`
|
||||
Seq int `json:"seq" alias:"ref"`
|
||||
Part string `json:"part"` // req | resp | both
|
||||
|
||||
// Corpus narrowing, mirroring history.list. These must stay in step with the
|
||||
@@ -93,7 +93,7 @@ func SearchCorpus(d SearchDeps, args SearchArgs) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if args.Ref > 0 {
|
||||
if args.Seq > 0 {
|
||||
return searchSingle(d, args, matcher, ctxBytes)
|
||||
}
|
||||
|
||||
@@ -176,9 +176,9 @@ func SearchCorpus(d SearchDeps, args SearchArgs) (string, error) {
|
||||
}
|
||||
|
||||
func searchSingle(d SearchDeps, args SearchArgs, m *matcher, ctxBytes int) (string, error) {
|
||||
item := d.Store.GetBySeq(args.Ref)
|
||||
item := d.Store.GetBySeq(args.Seq)
|
||||
if item == nil {
|
||||
return "", fmt.Errorf("no captured request with seq %d", args.Ref)
|
||||
return "", fmt.Errorf("no captured request with seq %d", args.Seq)
|
||||
}
|
||||
limit := clampInt(args.MaxMatches, DefaultMatchesSingle, 1, DefaultTotalMatches)
|
||||
|
||||
@@ -201,7 +201,7 @@ func searchSingle(d SearchDeps, args SearchArgs, m *matcher, ctxBytes int) (stri
|
||||
break
|
||||
}
|
||||
}
|
||||
t.note(fmt.Sprintf("q=%q mode=single seq=%d hits=%d", args.Pattern, args.Ref, hits))
|
||||
t.note(fmt.Sprintf("q=%q mode=single seq=%d hits=%d", args.Pattern, args.Seq, hits))
|
||||
if note := RedactionNote(redacted); note != "" {
|
||||
t.note(note)
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ function HTTPHistory() {
|
||||
await api.runScript({
|
||||
scriptId,
|
||||
trigger: 'request.selected',
|
||||
input: { ref: item.seq, method: item.method, url: item.url, host: item.host },
|
||||
input: { seq: item.seq, method: item.method, url: item.url, host: item.host },
|
||||
})
|
||||
)
|
||||
} catch (e) {
|
||||
|
||||
Reference in new issue
Block a user