Renamed the request argument ref to seq and suggested near-miss fields

This commit is contained in:
Tony West committed 2026-08-20 14:29:42 -04:00
1 parent 6a58a1ec93
commit 418208b237
12 files changed
+326 -108

No files matched your search

+8 -16
View File
@@ -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)
}
+226
View File
@@ -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)]
}
+9 -9
View File
@@ -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
}
+10 -10
View File
@@ -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
}),
})
}
+22 -22
View File
@@ -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
}
+4 -4
View File
@@ -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,
+6 -6
View File
@@ -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 {
+18 -18
View File
@@ -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
}
+6 -6
View File
@@ -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)
}
+10 -10
View File
@@ -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()
}
+6 -6
View File
@@ -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)
}
+1 -1
View File
@@ -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) {