mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-08 16:39:36 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
376880c88d | ||
|
|
f7097f22d1 | ||
|
|
bbe68abe7c | ||
|
|
ee6083e311 | ||
|
|
0f7b59fd0b | ||
|
|
78945ed001 | ||
|
|
647aebdba6 | ||
|
|
4b3c4766e2 | ||
|
|
e6a9f1f348 | ||
|
|
01ba289b5e |
No files matched your search
@@ -2,6 +2,8 @@ module github.com/BishopFox/joro
|
||||
|
||||
go 1.25.0
|
||||
|
||||
godebug multipathtcp=0
|
||||
|
||||
require github.com/hashicorp/go-uuid v1.0.3
|
||||
|
||||
require (
|
||||
|
||||
@@ -158,3 +158,18 @@ func (s *APIServer) handleClearRequests(w http.ResponseWriter, r *http.Request)
|
||||
s.clearDetectFindingsWithHistory()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
|
||||
}
|
||||
|
||||
// handleDeleteRequestsByHost removes captured requests whose host matches a glob,
|
||||
// the "clear what is already captured" companion to adding a noise pattern. The
|
||||
// required "pattern" query param is the host glob (filepath.Match), the same value
|
||||
// stored as the noise pattern. Removing the requests also drops them from the site
|
||||
// map, which is derived from the same store.
|
||||
func (s *APIServer) handleDeleteRequestsByHost(w http.ResponseWriter, r *http.Request) {
|
||||
pattern := r.URL.Query().Get("pattern")
|
||||
if pattern == "" {
|
||||
writeError(w, http.StatusBadRequest, "pattern is required")
|
||||
return
|
||||
}
|
||||
removed := s.store.DeleteByHostPattern(pattern)
|
||||
writeJSON(w, http.StatusOK, map[string]int{"deleted": removed})
|
||||
}
|
||||
@@ -140,11 +140,13 @@ func (s *APIServer) fetchSpecViaProxy(r *http.Request, rawURL, userAgent string)
|
||||
// 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.
|
||||
// When the page is an API-documentation UI (Swagger UI and its siblings all
|
||||
// embed or name the document they render), the hint names it and offers the
|
||||
// references it loads from — the most common failure becomes the next action.
|
||||
// A plain HTML page names nothing, so following its own scripts would list the
|
||||
// site's analytics and bundles as if they were documents; it is offered the
|
||||
// well-known document paths to sweep instead. The two are told apart by the
|
||||
// bytes, not assumed from "this is HTML", so neither verdict overclaims.
|
||||
func notADocument(body []byte, contentType, sourceURL string) map[string]any {
|
||||
isHTML := strings.Contains(strings.ToLower(contentType), "text/html") ||
|
||||
apispec.LooksLikeHTMLDocument(body)
|
||||
@@ -152,24 +154,43 @@ func notADocument(body []byte, contentType, sourceURL string) map[string]any {
|
||||
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
|
||||
}
|
||||
if ui := apispec.DetectDocUI(body); ui != "" {
|
||||
candidates := apispec.ExtractSpecRefs(body, sourceURL)
|
||||
if len(candidates) == 0 {
|
||||
candidates = priorityCandidates()
|
||||
}
|
||||
return map[string]any{
|
||||
"error": fmt.Sprintf("that URL served the %s page, not an API description document — these are the paths it loads from", ui),
|
||||
"kind": "html",
|
||||
"ui": ui,
|
||||
"contentType": contentType,
|
||||
"sourceUrl": sourceURL,
|
||||
"candidates": candidates,
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"error": "that URL served an HTML page, not an API description document",
|
||||
"error": "that URL served an HTML page, not an API description document — try these well-known document paths on this host",
|
||||
"kind": "html",
|
||||
"ui": "",
|
||||
"contentType": contentType,
|
||||
"sourceUrl": sourceURL,
|
||||
"candidates": candidates,
|
||||
"candidates": priorityCandidates(),
|
||||
}
|
||||
}
|
||||
|
||||
// priorityCandidates is the head of the standard discovery wordlist, offered as
|
||||
// paths to try when a page names no document of its own.
|
||||
func priorityCandidates() []string {
|
||||
var out []string
|
||||
for _, p := range apispec.CandidatePaths("", apispec.CandidatePriority) {
|
||||
out = append(out, p)
|
||||
if len(out) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSpecList(w http.ResponseWriter, r *http.Request) {
|
||||
type summary struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -90,6 +90,7 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/v1/requests/{id}", s.handleGetRequest)
|
||||
mux.HandleFunc("GET /api/v1/requests/{id}/params", s.handleGetRequestParams)
|
||||
mux.HandleFunc("DELETE /api/v1/requests", s.handleClearRequests)
|
||||
mux.HandleFunc("DELETE /api/v1/requests/by-host", s.handleDeleteRequestsByHost)
|
||||
|
||||
// Intercept
|
||||
mux.HandleFunc("GET /api/v1/intercept", s.handleGetInterceptQueue)
|
||||
|
||||
@@ -148,6 +148,48 @@ func LooksLikeHTMLDocument(body []byte) bool {
|
||||
strings.Contains(lower, "<head")
|
||||
}
|
||||
|
||||
// docUIMarkers name the API-documentation UI frameworks that embed or reference
|
||||
// the document they render, each by strings unambiguous enough that an ordinary
|
||||
// web page never carries them. Order is match priority; the Swagger entry leads
|
||||
// because it is far and away the common case. The Swagger bundle names overlap
|
||||
// uiBundleNames on purpose — the same scripts that are skipped when hunting for
|
||||
// an embedded document are what positively identify the page as Swagger UI.
|
||||
var docUIMarkers = []struct {
|
||||
label string
|
||||
markers []string
|
||||
}{
|
||||
{"Swagger UI", []string{
|
||||
"swagger-ui-bundle", "swagger-ui-standalone-preset", "swagger-ui.css",
|
||||
"swaggeruibundle", `id="swagger-ui"`, "id='swagger-ui'",
|
||||
"swagger-ui-init", "<title>swagger ui",
|
||||
}},
|
||||
{"ReDoc", []string{"redoc.standalone", "<redoc", "redoc.init("}},
|
||||
{"RapiDoc", []string{"<rapi-doc", "rapidoc-min.js"}},
|
||||
{"Stoplight Elements", []string{"<elements-api", "@stoplight/elements"}},
|
||||
{"Scalar", []string{"scalar.min.js", "@scalar/api-reference"}},
|
||||
}
|
||||
|
||||
// DetectDocUI reports which API-documentation UI framework an HTML page is, or
|
||||
// "" if it is just an HTML page. A doc UI embeds or names the document it loads,
|
||||
// so naming it turns the most common load failure into the next action; a plain
|
||||
// page must not be mislabelled as one.
|
||||
func DetectDocUI(body []byte) string {
|
||||
// Bound the scan: these markers sit in the page head, near the top.
|
||||
window := body
|
||||
if len(window) > 64<<10 {
|
||||
window = window[:64<<10]
|
||||
}
|
||||
lower := strings.ToLower(string(window))
|
||||
for _, ui := range docUIMarkers {
|
||||
for _, m := range ui.markers {
|
||||
if strings.Contains(lower, m) {
|
||||
return ui.label
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var (
|
||||
// specKeyRe matches a "url:" / "configUrl:" / "specUrl:" assignment, which is
|
||||
// how a Swagger UI page names the document it loads.
|
||||
|
||||
@@ -66,7 +66,7 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
|
||||
seen := map[[2]int]bool{}
|
||||
capped := false
|
||||
|
||||
record := func(v view, idx int32, start, end int, base int, coord string, part string, spans []ctxSpan) bool {
|
||||
record := func(buf []byte, v view, idx int32, start, end int, base int, coord string, part string, spans []ctxSpan) bool {
|
||||
if len(rep.Reflections) >= cfg.MaxReflectionsPerRequest {
|
||||
capped = true
|
||||
return false
|
||||
@@ -83,6 +83,10 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
|
||||
s = sinkAt(spans, srcStart)
|
||||
}
|
||||
sur := survivedChars(v, start, end)
|
||||
// The snippet is cut from the buffer the offsets index, so it is correct
|
||||
// regardless of coord: gzip bodies share no coordinates with the raw
|
||||
// document, which is why the window cannot be reconstructed client-side.
|
||||
snip, match := buildSnippet(buf, srcStart, srcEnd)
|
||||
val := ac.needles[idx]
|
||||
for _, vi := range index[val] {
|
||||
src := values[vi]
|
||||
@@ -95,6 +99,8 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
|
||||
Survived: sur,
|
||||
Breakout: breakout(s, sur),
|
||||
Confidence: confidence(val),
|
||||
RequestID: rep.RequestID, URL: rep.URL,
|
||||
Snippet: snip, SnippetMatch: match,
|
||||
})
|
||||
}
|
||||
return true
|
||||
@@ -105,7 +111,7 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
|
||||
if len(m.RespRawHdr) > 0 {
|
||||
for _, v := range buildViews(m.RespRawHdr, cfg) {
|
||||
ac.find(v.b, func(idx int32, s, e int) bool {
|
||||
return record(v, idx, s, e, 0, "raw", "response", nil)
|
||||
return record(m.RespRawHdr, v, idx, s, e, 0, "raw", "response", nil)
|
||||
})
|
||||
if capped {
|
||||
break
|
||||
@@ -124,7 +130,7 @@ func Analyze(r *proxy.CapturedRequest, cfg Config) *Report {
|
||||
}
|
||||
for _, v := range buildViews(m.RespBody, cfg) {
|
||||
ac.find(v.b, func(idx int32, s, e int) bool {
|
||||
return record(v, idx, s, e, base, coord, "response", spans)
|
||||
return record(m.RespBody, v, idx, s, e, base, coord, "response", spans)
|
||||
})
|
||||
if capped {
|
||||
break
|
||||
|
||||
@@ -178,6 +178,20 @@ type Reflection struct {
|
||||
// Breakout reports that Survived is enough to leave Context.
|
||||
Breakout bool `json:"breakout"`
|
||||
Confidence Confidence `json:"confidence"`
|
||||
|
||||
// RequestID and URL name the specific message this occurrence came from. An
|
||||
// Entry aggregates examples across requests, so an example's Span only makes
|
||||
// sense against its own response: these carry that response's identity.
|
||||
RequestID string `json:"requestId,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
// Snippet is a sanitized window of the response bytes around the match, and
|
||||
// SnippetMatch locates the matched run within Snippet (offsets into Snippet,
|
||||
// not the document). It lets the UI show the value in situ without refetching
|
||||
// and re-decoding the raw body, which a compressed response makes impossible
|
||||
// client-side. Bounded by snippetMaxBytes: a Reflection rides in capped slices
|
||||
// but many of them, so the byte cap is the budget, not conditional population.
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
SnippetMatch Span `json:"snippetMatch"`
|
||||
}
|
||||
|
||||
// SinkKey identifies a distinct place a parameter lands, for the roll-up.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package echo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Snippet bounds. A Reflection carries its snippet in capped slices but many of
|
||||
// them, so the window is the budget: snippetPad bytes of context on each side,
|
||||
// the whole window clamped to snippetMaxBytes. snippetMaxBytes mirrors detect's
|
||||
// maxEvidenceBytes so the two evidence surfaces stay the same size.
|
||||
const (
|
||||
snippetPad = 80
|
||||
snippetMaxBytes = 256
|
||||
)
|
||||
|
||||
// snippetWindow returns the raw byte range [lo, hi) of src to cut a snippet
|
||||
// from, given the matched run [start, end). It pads each side, then expands
|
||||
// outward to whole lines so the match reads with a line of context above and
|
||||
// below, and clamps the window to snippetMaxBytes with the match kept. lo <=
|
||||
// start and hi >= end always, except a match longer than the cap, whose head
|
||||
// alone is returned.
|
||||
func snippetWindow(src []byte, start, end int) (lo, hi int) {
|
||||
n := len(src)
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end > n {
|
||||
end = n
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
if end-start >= snippetMaxBytes {
|
||||
hi = min(start+snippetMaxBytes, n)
|
||||
return start, hi
|
||||
}
|
||||
|
||||
lo0 := max(start-snippetPad, 0)
|
||||
hi0 := min(end+snippetPad, n)
|
||||
// Expand to the line lo0 falls in and the line hi0 falls in, so a context
|
||||
// line survives on each side rather than collapsing to the match's own line.
|
||||
lo = 0
|
||||
if nl := bytes.LastIndexByte(src[:lo0], '\n'); nl >= 0 {
|
||||
lo = nl + 1
|
||||
}
|
||||
hi = n
|
||||
if nl := bytes.IndexByte(src[hi0:], '\n'); nl >= 0 {
|
||||
hi = hi0 + nl
|
||||
}
|
||||
|
||||
// Clamp to the byte cap, trimming context toward the match (a single line
|
||||
// longer than the budget is cut mid-way; the viewer wraps it).
|
||||
avail := snippetMaxBytes - (end - start)
|
||||
if start-lo > avail/2 {
|
||||
lo = start - avail/2
|
||||
}
|
||||
if hi-end > avail-avail/2 {
|
||||
hi = end + (avail - avail/2)
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
// sanitizeSnippet renders a response slice for display. It keeps newlines and
|
||||
// tabs so the window reads as the two or three lines around the match, drops
|
||||
// lone carriage returns (the newline alone breaks the line), and escapes other
|
||||
// control bytes to \xNN. Unlike detect's sanitizeEvidence it never truncates:
|
||||
// the three parts of a snippet are sanitized separately so the match offsets
|
||||
// stay exact, and snippetWindow has already bounded the raw input.
|
||||
func sanitizeSnippet(b []byte) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(b))
|
||||
for _, r := range string(b) {
|
||||
switch {
|
||||
case r == '\n' || r == '\t':
|
||||
sb.WriteRune(r)
|
||||
case r == '\r':
|
||||
// Dropped: a CRLF still breaks at the newline.
|
||||
case r < 0x20 || r == 0x7f:
|
||||
const hexDigits = "0123456789abcdef"
|
||||
sb.WriteString("\\x")
|
||||
sb.WriteByte(hexDigits[(r>>4)&0xf])
|
||||
sb.WriteByte(hexDigits[r&0xf])
|
||||
default:
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildSnippet cuts a display snippet from src around the matched run
|
||||
// [start, end) and returns it with the match located inside it. The match may
|
||||
// be clamped when it is longer than the cap; the caller's stored Span is
|
||||
// unaffected because that indexes the document, not the snippet.
|
||||
func buildSnippet(src []byte, start, end int) (string, Span) {
|
||||
lo, hi := snippetWindow(src, start, end)
|
||||
mEnd := min(end, hi)
|
||||
pre := sanitizeSnippet(src[lo:start])
|
||||
mid := sanitizeSnippet(src[start:mEnd])
|
||||
suf := sanitizeSnippet(src[mEnd:hi])
|
||||
return pre + mid + suf, Span{Start: len(pre), End: len(pre) + len(mid)}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package proxy
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -666,6 +667,37 @@ func (s *Store) DeleteSitemapNode(origin, path string, matchPath bool) int {
|
||||
return removed
|
||||
}
|
||||
|
||||
// DeleteByHostPattern removes every captured request whose host matches the given
|
||||
// host glob, using the same filepath.Match predicate (case-insensitive) that the
|
||||
// NoiseFilter applies in IsNoisy and Scope applies in matchRule. It is the "clear
|
||||
// what is already captured" companion to adding a noise pattern, so the rows removed
|
||||
// are exactly the ones the pattern suppresses going forward. Host is derived like
|
||||
// Sitemap/DeleteSitemapNode (u.Host); requests whose URL cannot be parsed are kept.
|
||||
// Returns the number of requests removed.
|
||||
func (s *Store) DeleteByHostPattern(hostPattern string) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
pat := strings.ToLower(hostPattern)
|
||||
kept := s.items[:0]
|
||||
removed := 0
|
||||
for _, r := range s.items {
|
||||
u, err := url.Parse(r.URL)
|
||||
if err != nil {
|
||||
kept = append(kept, r)
|
||||
continue
|
||||
}
|
||||
matched, merr := filepath.Match(pat, strings.ToLower(u.Host))
|
||||
if merr != nil || !matched {
|
||||
kept = append(kept, r)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
}
|
||||
s.items = kept
|
||||
return removed
|
||||
}
|
||||
|
||||
// Clear removes all stored requests.
|
||||
func (s *Store) Clear() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
"github.com/BishopFox/joro/internal/xsshunter"
|
||||
)
|
||||
|
||||
var version = "v1.18.0"
|
||||
var version = "v1.18.3"
|
||||
var commit = "dev" // injected via -ldflags at build time
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
title?: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** A centered, single-button warning dialog for surfacing an error the operator
|
||||
* must see — distinct from a transient toast. Mirrors ConfirmModal's overlay. */
|
||||
export default function WarningModal({
|
||||
title = 'Warning',
|
||||
message,
|
||||
confirmLabel = 'Dismiss',
|
||||
onClose,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' || e.key === 'Enter') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
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-md bg-surface-card border border-border rounded shadow-lg overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||
<AlertTriangle size={15} strokeWidth={2} className="text-semantic-warning shrink-0" aria-hidden="true" />
|
||||
<span className="text-xs font-semibold text-content-primary uppercase tracking-wide">{title}</span>
|
||||
</div>
|
||||
<div className="px-4 py-4">
|
||||
<p className="text-sm text-content-primary break-words">{message}</p>
|
||||
</div>
|
||||
<div className="flex justify-end px-4 py-3 border-t border-border">
|
||||
<button
|
||||
onClick={onClose}
|
||||
autoFocus
|
||||
className="px-3 py-1.5 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary text-xs font-semibold"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,59 +2,95 @@ import { useEffect, useState } from 'react'
|
||||
import { ArrowUpRight, ShieldAlert } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { api } from '../../lib/api'
|
||||
import type { EchoEntry, EchoReflection } from '../../lib/echoTypes'
|
||||
import type { EchoEntry, EchoReflection, EchoSpan } from '../../lib/echoTypes'
|
||||
import {
|
||||
CONTEXT_LABELS,
|
||||
describeReflection,
|
||||
prettyParamName,
|
||||
SOURCE_LABELS,
|
||||
TRANSFORM_LABELS,
|
||||
survivedLabel,
|
||||
} from '../../lib/echoTypes'
|
||||
|
||||
/** survivedLabel renders the characters that made it through readably. */
|
||||
function survivedLabel(s?: string): string {
|
||||
if (!s) return 'nothing'
|
||||
return [...s]
|
||||
.map((c) => (c === '\n' ? 'LF' : c === '\r' ? 'CR' : c))
|
||||
.join(' ')
|
||||
/** renderSnippet highlights the matched run inside the response snippet. */
|
||||
function renderSnippet(snippet: string, match?: EchoSpan) {
|
||||
if (!match) return snippet
|
||||
const start = Math.max(0, Math.min(match.start, snippet.length))
|
||||
const end = Math.max(start, Math.min(match.end, snippet.length))
|
||||
return (
|
||||
<>
|
||||
{snippet.slice(0, start)}
|
||||
<mark className="bg-transparent font-semibold text-semantic-warning">
|
||||
{snippet.slice(start, end)}
|
||||
</mark>
|
||||
{snippet.slice(end)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ReflectionRow({ r }: { r: EchoReflection }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="border-b border-border-subtle px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{r.breakout && (
|
||||
<span className="inline-flex items-center gap-1 text-semantic-error">
|
||||
<ShieldAlert size={12} />
|
||||
can leave context
|
||||
<span className="inline-flex items-center gap-1 rounded bg-semantic-error-bg px-1.5 py-0.5 text-[11px] font-medium text-semantic-error">
|
||||
<ShieldAlert size={12} /> Can leave context
|
||||
</span>
|
||||
)}
|
||||
<span className="text-content-secondary">
|
||||
{TRANSFORM_LABELS[r.transform] ?? r.transform}
|
||||
</span>
|
||||
<span className="text-content-muted">in</span>
|
||||
<span className="text-content-secondary">
|
||||
{CONTEXT_LABELS[r.context] ?? r.context}
|
||||
{r.attr ? ` (${r.attr}${r.quote ? `, ${r.quote}-quoted` : ', unquoted'})` : ''}
|
||||
{r.jsContext && r.jsContext !== 'code'
|
||||
? ` / ${r.jsContext.replace('_', ' ')}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="text-content-muted">{r.confidence} confidence</span>
|
||||
<span className="text-xs text-content-primary">{describeReflection(r)}</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-content-muted">
|
||||
survived: <span className="text-content-secondary">{survivedLabel(r.survived)}</span>
|
||||
<span className="mx-2">·</span>
|
||||
{r.part} bytes {r.span.start}-{r.span.end}
|
||||
|
||||
<div className="mt-1 font-mono text-[11px]">
|
||||
<span className="text-content-muted">{SOURCE_LABELS[r.source] ?? r.source}:</span>{' '}
|
||||
<span className="text-content-secondary">{prettyParamName(r.source, r.name)}</span>
|
||||
</div>
|
||||
|
||||
{r.url && (
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px]">
|
||||
<span className="truncate font-mono text-content-muted" title={r.url}>
|
||||
{r.url}
|
||||
</span>
|
||||
{r.requestId && (
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center gap-1 text-accent-secondary hover:underline"
|
||||
onClick={() => navigate('/history', { state: { focusRequestId: r.requestId } })}
|
||||
>
|
||||
Open <ArrowUpRight size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.snippet ? (
|
||||
<pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap break-all rounded bg-surface-input px-1.5 py-1 font-mono text-[11px] text-content-secondary">
|
||||
{renderSnippet(r.snippet, r.snippetMatch)}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-content-muted">
|
||||
value: {r.value}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-content-muted">
|
||||
<span>
|
||||
survived:{' '}
|
||||
<span className={r.survived ? 'text-semantic-warning' : 'text-content-muted'}>
|
||||
{survivedLabel(r.survived)}
|
||||
</span>
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{r.part} bytes {r.span.start}-{r.span.end}
|
||||
</span>
|
||||
{r.coord === 'decoded-body' && (
|
||||
<span
|
||||
className="ml-2 text-semantic-info"
|
||||
className="text-semantic-info"
|
||||
title="The response was compressed, so these offsets index the decompressed body rather than the raw document."
|
||||
>
|
||||
decoded-body offsets
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-content-muted">
|
||||
value: {r.value}
|
||||
<span>·</span>
|
||||
<span>{r.confidence} confidence</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -81,12 +117,18 @@ export function EchoParamDetail({ entry }: { entry: EchoEntry }) {
|
||||
}, [entry.id])
|
||||
|
||||
const examples = full?.examples ?? []
|
||||
const pretty = prettyParamName(entry.source, entry.name)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-content-primary">{entry.name}</span>
|
||||
<span className="text-sm text-content-primary">{pretty}</span>
|
||||
{pretty !== entry.name && (
|
||||
<span className="rounded bg-surface-input px-1 py-0.5 font-mono text-[11px] text-content-muted">
|
||||
{entry.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-surface-input px-1.5 py-0.5 text-[11px] text-content-muted">
|
||||
{SOURCE_LABELS[entry.source] ?? entry.source}
|
||||
</span>
|
||||
@@ -94,7 +136,9 @@ export function EchoParamDetail({ entry }: { entry: EchoEntry }) {
|
||||
{entry.exampleRequestId && (
|
||||
<button
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs text-accent-secondary hover:underline"
|
||||
onClick={() => navigate('/history', { state: { focusRequestId: entry.exampleRequestId } })}
|
||||
onClick={() =>
|
||||
navigate('/history', { state: { focusRequestId: entry.exampleRequestId } })
|
||||
}
|
||||
>
|
||||
Open in History <ArrowUpRight size={12} />
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { api } from '../../lib/api'
|
||||
import type { EchoReport } from '../../lib/echoTypes'
|
||||
import {
|
||||
CONTEXT_LABELS,
|
||||
prettyParamName,
|
||||
SOURCE_LABELS,
|
||||
survivedLabel,
|
||||
TRANSFORM_LABELS,
|
||||
} from '../../lib/echoTypes'
|
||||
|
||||
@@ -96,7 +98,7 @@ export function EchoRequestPane({ requestId }: { requestId: string }) {
|
||||
<span className="text-content-muted">
|
||||
{SOURCE_LABELS[r.source] ?? r.source}:
|
||||
</span>{' '}
|
||||
{r.name}
|
||||
{prettyParamName(r.source, r.name)}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-content-muted">
|
||||
{TRANSFORM_LABELS[r.transform] ?? r.transform}
|
||||
@@ -109,11 +111,7 @@ export function EchoRequestPane({ requestId }: { requestId: string }) {
|
||||
: ''}
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono text-content-secondary">
|
||||
{r.survived
|
||||
? [...r.survived]
|
||||
.map((c) => (c === '\n' ? 'LF' : c === '\r' ? 'CR' : c))
|
||||
.join(' ')
|
||||
: '-'}
|
||||
{r.survived ? survivedLabel(r.survived) : '-'}
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-1 text-right font-mono text-content-muted"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import type { EchoSink, EchoTransform } from '../../lib/echoTypes'
|
||||
import { CONTEXT_LABELS, TRANSFORM_LABELS } from '../../lib/echoTypes'
|
||||
import { contextShort, describeSink, TRANSFORM_LABELS } from '../../lib/echoTypes'
|
||||
|
||||
/**
|
||||
* Tone maps a transform to how much of the value reached the page intact. It is
|
||||
@@ -19,12 +19,7 @@ function transformTone(t: EchoTransform): string {
|
||||
}
|
||||
|
||||
export function EchoSinkPill({ sink }: { sink: EchoSink }) {
|
||||
const ctx =
|
||||
sink.context === 'attr_value' && sink.attr
|
||||
? `${sink.attr}=`
|
||||
: sink.context === 'script' && sink.jsContext && sink.jsContext !== 'code'
|
||||
? `script ${sink.jsContext.replace('_', ' ')}`
|
||||
: (CONTEXT_LABELS[sink.context] ?? sink.context)
|
||||
const ctx = contextShort(sink)
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -32,9 +27,7 @@ export function EchoSinkPill({ sink }: { sink: EchoSink }) {
|
||||
bg-surface-input px-1.5 py-0.5 text-[11px] leading-none ${
|
||||
sink.breakout ? 'border-semantic-error' : ''
|
||||
}`}
|
||||
title={`${TRANSFORM_LABELS[sink.transform] ?? sink.transform} in ${
|
||||
CONTEXT_LABELS[sink.context] ?? sink.context
|
||||
}${sink.attr ? ` (${sink.attr})` : ''} - seen ${sink.count}x`}
|
||||
title={`${describeSink(sink)} - seen ${sink.count}x`}
|
||||
>
|
||||
{sink.breakout && <ShieldAlert size={11} className="text-semantic-error" />}
|
||||
<span className={transformTone(sink.transform)}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Upload, Link2, ClipboardPaste, AlertTriangle, Search } from 'lucide-react'
|
||||
import { Upload, Link2, ClipboardPaste, Search } from 'lucide-react'
|
||||
import { api } from '../../lib/api'
|
||||
import { b64DecodeUTF8 } from '../../lib/bytes'
|
||||
import { useSJStore, type SJTab } from '../../stores/sjStore'
|
||||
@@ -65,7 +65,7 @@ export default function SJDocumentModal({
|
||||
}
|
||||
|
||||
async function load() {
|
||||
store.updateTab(tab.id, { loading: true, loadError: '', htmlHint: null })
|
||||
store.updateTab(tab.id, { loading: true, htmlHint: null })
|
||||
try {
|
||||
const body =
|
||||
source === 'url'
|
||||
@@ -206,13 +206,6 @@ export default function SJDocumentModal({
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1191,6 +1191,10 @@ export const api = {
|
||||
if (path !== undefined) qs.set('path', path)
|
||||
return req<{ deleted: number }>('DELETE', `/sitemap?${qs.toString()}`)
|
||||
},
|
||||
// Delete captured requests whose host matches a glob (the host-level companion to
|
||||
// adding a noise pattern). pattern is the host glob stored as the noise pattern.
|
||||
deleteRequestsByHost: (pattern: string) =>
|
||||
req<{ deleted: number }>('DELETE', `/requests/by-host?${new URLSearchParams({ pattern }).toString()}`),
|
||||
// Every parameter the node's requests carried. The node is 'origin', not 'host'
|
||||
// as deleteSitemapNode names it: this endpoint also takes the tree's filters,
|
||||
// where 'host' means a substring of the captured Host header. Filters must match
|
||||
|
||||
@@ -45,6 +45,13 @@ export interface EchoReflection {
|
||||
survived?: string
|
||||
breakout: boolean
|
||||
confidence: EchoConfidence
|
||||
/** The specific message this occurrence came from (an Entry mixes several). */
|
||||
requestId?: string
|
||||
url?: string
|
||||
/** A sanitized window of the response around the match, and where the match
|
||||
* sits inside it (offsets into `snippet`, not the document). */
|
||||
snippet?: string
|
||||
snippetMatch?: EchoSpan
|
||||
}
|
||||
|
||||
export interface EchoSink {
|
||||
@@ -230,13 +237,80 @@ export const CONTEXT_OPTIONS = Object.keys(CONTEXT_LABELS) as EchoContext[]
|
||||
export const TRANSFORM_OPTIONS = Object.keys(TRANSFORM_LABELS) as EchoTransform[]
|
||||
export const SOURCE_OPTIONS = Object.keys(SOURCE_LABELS) as EchoSource[]
|
||||
|
||||
/**
|
||||
* prettyParamName turns a server-synthesized positional name into something a
|
||||
* human reads. The server indexes by position: a URL path segment becomes
|
||||
* [1],[2],[3]; a root-level JSON array element is a bare [2]. Real names and
|
||||
* dotted JSON paths (user.items[2].name) pass through untouched.
|
||||
*/
|
||||
export function prettyParamName(source: EchoSource, name: string): string {
|
||||
const m = /^\[(\d+)\]$/.exec(name) // only a BARE positional token
|
||||
if (m) {
|
||||
if (source === 'path') return `path segment ${m[1]}`
|
||||
if (source === 'json') return `array item ${m[1]}`
|
||||
}
|
||||
if (!name) return source === 'json' ? '(request body)' : '(unnamed)'
|
||||
return name
|
||||
}
|
||||
|
||||
/** transformWord names an encoding as a mid-sentence word. */
|
||||
export function transformWord(t: EchoTransform): string {
|
||||
return t === 'identity' ? 'unencoded' : TRANSFORM_LABELS[t] ?? t
|
||||
}
|
||||
|
||||
/** A shape both EchoReflection and EchoSink satisfy, so one set of context
|
||||
* helpers serves reflections, sinks and request rows. */
|
||||
type Contextual = Pick<EchoReflection, 'context' | 'attr' | 'quote' | 'jsContext'>
|
||||
|
||||
/** contextShort is the compact noun for a pill or a table cell. */
|
||||
export function contextShort(x: Contextual): string {
|
||||
if (x.context === 'attr_value' && x.attr) return `${x.attr}=`
|
||||
if (x.context === 'script' && x.jsContext && x.jsContext !== 'code')
|
||||
return `script ${x.jsContext.replace('_', ' ')}`
|
||||
return CONTEXT_LABELS[x.context] ?? x.context
|
||||
}
|
||||
|
||||
/** contextProse is the full phrase for a sentence; mirrors the server's
|
||||
* describe() in internal/echo/finding.go. */
|
||||
export function contextProse(x: Contextual): string {
|
||||
switch (x.context) {
|
||||
case 'attr_value': {
|
||||
// A reflection reports quote '' for a genuinely unquoted value; a sink
|
||||
// carries no quote at all, so only claim quotedness when it is known.
|
||||
if (x.quote) {
|
||||
return x.attr
|
||||
? `the ${x.quote}-quoted ${x.attr} attribute`
|
||||
: `a ${x.quote}-quoted attribute`
|
||||
}
|
||||
if (x.quote === '') {
|
||||
return x.attr ? `an unquoted ${x.attr} attribute` : 'an unquoted attribute'
|
||||
}
|
||||
return x.attr ? `the ${x.attr} attribute` : 'an attribute'
|
||||
}
|
||||
case 'script':
|
||||
return !x.jsContext || x.jsContext === 'code'
|
||||
? 'script code'
|
||||
: `a script ${x.jsContext.replace('_', ' ')}`
|
||||
case 'header_value':
|
||||
return 'a response header'
|
||||
default:
|
||||
return CONTEXT_LABELS[x.context] ?? x.context
|
||||
}
|
||||
}
|
||||
|
||||
/** describeReflection is the plain-English lead for a reflection row, e.g.
|
||||
* "Reflected unencoded into the "-quoted href attribute". */
|
||||
export function describeReflection(r: EchoReflection): string {
|
||||
return `Reflected ${transformWord(r.transform)} into ${contextProse(r)}`
|
||||
}
|
||||
|
||||
/** survivedLabel renders surviving metacharacters readably (LF/CR named). */
|
||||
export function survivedLabel(s?: string): string {
|
||||
if (!s) return 'nothing'
|
||||
return [...s].map((c) => (c === '\n' ? 'LF' : c === '\r' ? 'CR' : c)).join(' ')
|
||||
}
|
||||
|
||||
/** describeSink renders one sink as a short phrase for a row. */
|
||||
export function describeSink(s: EchoSink): string {
|
||||
const ctx =
|
||||
s.context === 'attr_value' && s.attr
|
||||
? `${s.attr} attribute`
|
||||
: s.context === 'script' && s.jsContext && s.jsContext !== 'code'
|
||||
? `script ${s.jsContext.replace('_', ' ')}`
|
||||
: CONTEXT_LABELS[s.context] ?? s.context
|
||||
return `${TRANSFORM_LABELS[s.transform] ?? s.transform} in ${ctx}`
|
||||
return `${TRANSFORM_LABELS[s.transform] ?? s.transform} in ${contextProse(s)}`
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { MenuItem } from '../components/ContextMenu'
|
||||
import { api } from './api'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
import { useRequestStore } from '../stores/requestStore'
|
||||
|
||||
// Host-level context-menu actions shared by the History table rows and the Site Map host
|
||||
// menu. Modeled on getSelectionMenuItems. Scope and noise both match a host glob with
|
||||
// filepath.Match (internal/proxy/scope.go, noise.go), so patterns are the host verbatim.
|
||||
|
||||
// hostnameOnly strips a trailing :port, leaving IPv6 bracket forms intact.
|
||||
function hostnameOnly(host: string): string {
|
||||
const close = host.lastIndexOf(']')
|
||||
const colon = host.lastIndexOf(':')
|
||||
return colon > close ? host.slice(0, colon) : host
|
||||
}
|
||||
|
||||
function toast(msg: string, type: 'error' | 'info' = 'info') {
|
||||
useToastStore.getState().addToast(msg, type)
|
||||
}
|
||||
|
||||
async function addScope(pattern: string, path: string, label: string) {
|
||||
try {
|
||||
const { enabled } = await api.getScope()
|
||||
await api.addScopeRule({ pattern, methods: [], path, include: true })
|
||||
toast(enabled ? `Added ${label} to scope` : `Added ${label} to scope — scope filtering is off`)
|
||||
} catch {
|
||||
toast('Failed to add scope rule', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// addNoise adds a noise pattern and then purges the already-captured requests it matches:
|
||||
// noise means traffic to never see, so leaving the old rows behind would be the surprise.
|
||||
// invalidate() reloads History and, via the total change, the Site Map (both derive from the
|
||||
// same store). The deletion predicate is the server's, matching the pattern just stored.
|
||||
async function addNoise(pattern: string, label: string) {
|
||||
try {
|
||||
const { enabled } = await api.getNoise()
|
||||
await api.addNoisePattern(pattern)
|
||||
const { deleted } = await api.deleteRequestsByHost(pattern)
|
||||
if (deleted > 0) useRequestStore.getState().invalidate()
|
||||
const cleared = deleted > 0 ? ` — cleared ${deleted} request${deleted === 1 ? '' : 's'}` : ''
|
||||
toast(enabled ? `Added ${label} to noise filter${cleared}` : `Added ${label} to noise filter — noise filtering is off${cleared}`)
|
||||
} catch {
|
||||
toast('Failed to add noise pattern', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// hostMatchesGlob is a best-effort mirror of filepath.Match for host patterns (which carry no
|
||||
// path separator): * matches any run of characters, ? matches one. Advisory only — it drives a
|
||||
// warning, never a scope decision, so an exotic char-class pattern merely skips the warning.
|
||||
function hostMatchesGlob(pattern: string, host: string): boolean {
|
||||
let re = ''
|
||||
for (const c of pattern) {
|
||||
if (c === '*') re += '.*'
|
||||
else if (c === '?') re += '.'
|
||||
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
try {
|
||||
return new RegExp(`^${re}$`).test(host)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// excludeScope adds an exclude rule (include:false) for a scope target, but only if an identical
|
||||
// one does not already exist. It warns when the rule would currently have no effect: excludes
|
||||
// only subtract from an included host, so with scope off, or with no include rule matching this
|
||||
// host, there is nothing in scope for it to remove.
|
||||
async function excludeScope(pattern: string, path: string, label: string, host: string) {
|
||||
try {
|
||||
const { enabled, rules } = await api.getScope()
|
||||
const exists = rules.some(
|
||||
(r) => !r.include && r.pattern === pattern.toLowerCase() && r.path === path,
|
||||
)
|
||||
if (exists) {
|
||||
toast(`${label} is already excluded from scope`)
|
||||
return
|
||||
}
|
||||
await api.addScopeRule({ pattern, methods: [], path, include: false })
|
||||
const h = host.toLowerCase()
|
||||
if (!enabled) {
|
||||
toast(`Excluded ${label} — scope filtering is off`)
|
||||
} else if (!rules.some((r) => r.include && hostMatchesGlob(r.pattern, h))) {
|
||||
toast(`Excluded ${label} — no in-scope rule matches ${host}, so this has no effect`, 'error')
|
||||
} else {
|
||||
toast(`Excluded ${label} from scope`)
|
||||
}
|
||||
} catch {
|
||||
toast('Failed to add exclude rule', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// removeHostFromScope deletes the include rules this feature creates for a host: the exact
|
||||
// host (This host / Exact URL) and the subdomain wildcard. It leaves operator-authored broad
|
||||
// wildcards and exclude rules alone.
|
||||
async function removeHostFromScope(host: string) {
|
||||
try {
|
||||
const exact = host.toLowerCase()
|
||||
const wildcard = `*.${hostnameOnly(host).toLowerCase()}`
|
||||
const { rules } = await api.getScope()
|
||||
const victims = rules.filter((r) => r.include && (r.pattern === exact || r.pattern === wildcard))
|
||||
if (victims.length === 0) {
|
||||
toast(`No scope rules matched ${host}`)
|
||||
return
|
||||
}
|
||||
await Promise.all(victims.map((r) => api.deleteScopeRule(r.id)))
|
||||
toast(`Removed ${victims.length} scope rule${victims.length === 1 ? '' : 's'} for ${host}`)
|
||||
} catch {
|
||||
toast('Failed to remove scope rules', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// scopeTargets lists the scope targets for a host: the exact host, the subdomain wildcard, and
|
||||
// (when a URL is available) the request's exact path. Shared by the scope-add and scope-exclude
|
||||
// submenus so the two never drift.
|
||||
function scopeTargets(host: string, opts?: { url?: string }) {
|
||||
const wildcard = `*.${hostnameOnly(host)}`
|
||||
const targets = [
|
||||
{ label: 'This host', pattern: host, path: '', display: host },
|
||||
{ label: 'All subdomains', pattern: wildcard, path: '', display: wildcard },
|
||||
]
|
||||
if (opts?.url) {
|
||||
try {
|
||||
const path = new URL(opts.url).pathname
|
||||
targets.push({ label: 'Exact URL', pattern: host, path, display: `${host}${path}` })
|
||||
} catch {
|
||||
// Unparseable URL: skip the path-scoped option.
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// getHostMenuItems builds the host actions for a context menu. When opts.url is given, the
|
||||
// scope submenus also offer the request's exact URL (host + path); noise has no path field,
|
||||
// so its submenu never does.
|
||||
export function getHostMenuItems(host: string, opts?: { url?: string }): MenuItem[] {
|
||||
const wildcard = `*.${hostnameOnly(host)}`
|
||||
const targets = scopeTargets(host, opts)
|
||||
|
||||
const scopeChildren: MenuItem[] = targets.map((t) => ({
|
||||
label: t.label,
|
||||
onClick: () => void addScope(t.pattern, t.path, t.display),
|
||||
}))
|
||||
const excludeChildren: MenuItem[] = targets.map((t) => ({
|
||||
label: t.label,
|
||||
onClick: () => void excludeScope(t.pattern, t.path, t.display, host),
|
||||
}))
|
||||
|
||||
return [
|
||||
{ label: 'Add host to scope', children: scopeChildren },
|
||||
{
|
||||
label: 'Add host to noise filter',
|
||||
children: [
|
||||
{ label: 'This host', onClick: () => void addNoise(host, host) },
|
||||
{ label: 'All subdomains', onClick: () => void addNoise(wildcard, wildcard) },
|
||||
],
|
||||
},
|
||||
{ label: 'Exclude from scope', children: excludeChildren },
|
||||
{ label: 'Remove host from scope', onClick: () => void removeHostFromScope(host) },
|
||||
]
|
||||
}
|
||||
+18
-6
@@ -11,6 +11,7 @@ import type { EchoContext, EchoSource, EchoTransform } from '../lib/echoTypes'
|
||||
import {
|
||||
CONTEXT_LABELS,
|
||||
CONTEXT_OPTIONS,
|
||||
prettyParamName,
|
||||
SOURCE_LABELS,
|
||||
SOURCE_OPTIONS,
|
||||
TRANSFORM_LABELS,
|
||||
@@ -115,14 +116,15 @@ export default function Echo() {
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-border px-3 py-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<label className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => void toggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="text-content-primary">Reflection mapping</span>
|
||||
Reflection mapping
|
||||
</label>
|
||||
|
||||
<span className="text-xs text-content-muted">
|
||||
@@ -241,16 +243,17 @@ export default function Echo() {
|
||||
<th className="px-2 py-1 font-normal">Parameter</th>
|
||||
<th className="px-2 py-1 font-normal">Source</th>
|
||||
<th className="px-2 py-1 font-normal">Host</th>
|
||||
<th className="px-2 py-1 font-normal">Example</th>
|
||||
<th className="px-2 py-1 font-normal">Lands in</th>
|
||||
<th className="px-2 py-1 text-right font-normal">Seen</th>
|
||||
<th className="px-2 py-1 text-right font-normal">Values</th>
|
||||
<th className="px-2 py-1 text-right font-normal">Distinct</th>
|
||||
<th className="px-2 py-1 font-normal">Last</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-2 py-8 text-center text-xs text-content-muted">
|
||||
<td colSpan={8} className="px-2 py-8 text-center text-xs text-content-muted">
|
||||
{!enabled
|
||||
? 'Reflection mapping is off. Switch it on, then browse through the proxy or run a backfill.'
|
||||
: summary.params === 0
|
||||
@@ -267,16 +270,25 @@ export default function Echo() {
|
||||
}`}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<td className="max-w-[16rem] truncate px-2 py-1 text-content-secondary">
|
||||
<td
|
||||
className="max-w-[16rem] truncate px-2 py-1 text-content-secondary"
|
||||
title={`${e.name}${e.exampleUrl ? `\n${e.exampleUrl}` : ''}`}
|
||||
>
|
||||
{e.breakouts > 0 && (
|
||||
<ShieldAlert size={12} className="mr-1 inline text-semantic-error" />
|
||||
)}
|
||||
{e.name}
|
||||
{prettyParamName(e.source, e.name)}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-content-muted">
|
||||
{SOURCE_LABELS[e.source] ?? e.source}
|
||||
</td>
|
||||
<td className="max-w-[12rem] truncate px-2 py-1 text-content-muted">{e.host}</td>
|
||||
<td
|
||||
className="max-w-[12rem] truncate px-2 py-1 font-mono text-content-secondary"
|
||||
title={e.exampleValue}
|
||||
>
|
||||
{e.exampleValue || <span className="text-content-muted">—</span>}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{e.sinks.slice(0, 4).map((s, i) => (
|
||||
|
||||
@@ -23,6 +23,7 @@ import ContextMenu from '../components/ContextMenu'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { getSelectionMenuItems } from '../lib/selectionMenu'
|
||||
import { getHostMenuItems } from '../lib/hostMenu'
|
||||
import { initiateScan } from '../lib/scanMenu'
|
||||
import { useScanOptionsStore } from '../stores/scanOptionsStore'
|
||||
import { copyText } from '../lib/clipboard'
|
||||
@@ -640,6 +641,16 @@ function HTTPHistory() {
|
||||
copyText(rawToCurl(raw, selectedDetail.url))
|
||||
}
|
||||
|
||||
// Row menu carries only the summary, so fetch the raw bytes to build the curl command.
|
||||
async function copyCurlRow(id: string) {
|
||||
try {
|
||||
const d = (await api.getRequest(id)) as RequestDetail
|
||||
copyText(rawToCurl(b64Decode(d.reqRaw), d.url))
|
||||
} catch {
|
||||
addToast('Failed to copy curl')
|
||||
}
|
||||
}
|
||||
|
||||
function copyRaw(tab: 'request' | 'response') {
|
||||
if (!selectedDetail) return
|
||||
const raw = tab === 'request' ? selectedDetail.reqRaw : selectedDetail.respRaw
|
||||
@@ -700,9 +711,8 @@ function HTTPHistory() {
|
||||
// The right-clicked row's capture sequence. Taken from the summary the table
|
||||
// already holds rather than from the loaded detail, because the row menu can
|
||||
// open on a row that is not the selected one.
|
||||
const rowSeq = rowMenu
|
||||
? sortedItems.find((i) => i.id === rowMenu.itemId)?.seq
|
||||
: undefined
|
||||
const rowSummary = rowMenu ? sortedItems.find((i) => i.id === rowMenu.itemId) : undefined
|
||||
const rowSeq = rowSummary?.seq
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0" ref={vSplit.containerRef}>
|
||||
@@ -1162,6 +1172,13 @@ function HTTPHistory() {
|
||||
}),
|
||||
},
|
||||
{ label: 'Stage for Dead Drop', onClick: () => stageForDeadDrop(rowMenu.itemId) },
|
||||
...(rowSummary ? getHostMenuItems(rowSummary.host, { url: rowSummary.url }) : []),
|
||||
...(rowSummary
|
||||
? [
|
||||
{ label: 'Copy URL', onClick: () => copyText(rowSummary.url) },
|
||||
{ label: 'Copy as curl', onClick: () => copyCurlRow(rowSummary.id) },
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { EchoRequestPane } from '../components/echo/EchoRequestPane'
|
||||
import { ParamInventory } from '../components/echo/ParamInventory'
|
||||
import { RequestParams } from '../components/echo/RequestParams'
|
||||
import { getSelectionMenuItems } from '../lib/selectionMenu'
|
||||
import { getHostMenuItems } from '../lib/hostMenu'
|
||||
import { copyText } from '../lib/clipboard'
|
||||
import SitemapFilterModal, { emptySitemapFilter, hasModalFilters } from '../components/SitemapFilterModal'
|
||||
import type { SitemapFilter } from '../components/SitemapFilterModal'
|
||||
@@ -41,6 +42,11 @@ function b64Decode(s: string) {
|
||||
try { return atob(s) } catch { return s }
|
||||
}
|
||||
|
||||
// A sitemap host node's origin is a full origin URL; the host menu wants the bare host.
|
||||
function hostFromOrigin(origin: string): string {
|
||||
try { return new URL(origin).host } catch { return origin }
|
||||
}
|
||||
|
||||
export default function Map() {
|
||||
const navigate = useNavigate()
|
||||
const [hosts, setHosts] = useState<SitemapHost[]>([])
|
||||
@@ -709,6 +715,7 @@ export default function Map() {
|
||||
}
|
||||
},
|
||||
},
|
||||
...getHostMenuItems(hostFromOrigin(hostMenu.origin)),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
+18
-5
@@ -7,6 +7,7 @@ import type { SJNavState } from '../lib/sjTypes'
|
||||
import type { RequestDetail } from '../stores/requestStore'
|
||||
import SJDocumentBar from '../components/sj/SJDocumentBar'
|
||||
import SJDocumentModal, { type DocTab } from '../components/sj/SJDocumentModal'
|
||||
import WarningModal from '../components/WarningModal'
|
||||
import SJProfilesModal from '../components/sj/SJProfilesModal'
|
||||
import SJOperationsView from '../components/sj/SJOperationsView'
|
||||
import SJAutomateView from '../components/sj/SJAutomateView'
|
||||
@@ -42,12 +43,15 @@ export default function SJ() {
|
||||
const tab = tabs.find((t) => t.id === activeTabId) ?? tabs[0]
|
||||
const [docModal, setDocModal] = useState<DocTab | null>(null)
|
||||
const [profilesOpen, setProfilesOpen] = useState(false)
|
||||
const [loadErrorMsg, setLoadErrorMsg] = useState<string | null>(null)
|
||||
|
||||
/** applyLoadFailure turns a rejection into either an error or, for the very
|
||||
* common "that was the Swagger UI page" case, a pre-filled Brute tab. */
|
||||
/** applyLoadFailure turns a rejection into either an error or, when a URL
|
||||
* served an HTML page instead of a document, a pre-filled Brute tab. The
|
||||
* verdict and its message come from the server, which has the bytes and
|
||||
* decides whether the page is an API-documentation UI or just HTML. */
|
||||
const applyLoadFailure = useCallback((tabId: string, e: unknown) => {
|
||||
const err = e as { message?: string; body?: unknown }
|
||||
const body = (err as { body?: { kind?: string; candidates?: string[]; sourceUrl?: string } }).body
|
||||
const body = (err as { body?: { kind?: string; error?: string; candidates?: string[]; sourceUrl?: string } }).body
|
||||
if (body?.kind === 'html') {
|
||||
const t = useSJStore.getState().tabs.find((x) => x.id === tabId)
|
||||
store.updateTab(tabId, {
|
||||
@@ -55,7 +59,7 @@ export default function SJ() {
|
||||
subTab: 'brute',
|
||||
htmlHint: {
|
||||
message:
|
||||
'That URL served the Swagger UI page, not a document. These are the paths it loads from.',
|
||||
body.error ?? 'That URL served an HTML page, not an API description document.',
|
||||
candidates: body.candidates ?? [],
|
||||
sourceUrl: body.sourceUrl ?? '',
|
||||
},
|
||||
@@ -66,7 +70,8 @@ export default function SJ() {
|
||||
})
|
||||
return
|
||||
}
|
||||
store.updateTab(tabId, { loading: false, loadError: String(err?.message ?? e) })
|
||||
store.updateTab(tabId, { loading: false })
|
||||
setLoadErrorMsg(String(err?.message ?? e))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
@@ -205,6 +210,14 @@ export default function SJ() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{loadErrorMsg && (
|
||||
<WarningModal
|
||||
title="Couldn't load document"
|
||||
message={loadErrorMsg}
|
||||
onClose={() => setLoadErrorMsg(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profilesOpen && (
|
||||
<SJProfilesModal
|
||||
profiles={tab.profiles}
|
||||
|
||||
@@ -158,7 +158,6 @@ export interface SJTab {
|
||||
|
||||
spec: SJSpec | null
|
||||
loading: boolean
|
||||
loadError: string
|
||||
/** Set when a load returned "that was the UI page, not a document". Carries
|
||||
* the paths Discover should be pre-filled with. */
|
||||
htmlHint: { message: string; candidates: string[]; sourceUrl: string } | null
|
||||
@@ -212,7 +211,6 @@ function makeTab(id: string, name: string, partial?: Partial<SJTab>): SJTab {
|
||||
name,
|
||||
spec: null,
|
||||
loading: false,
|
||||
loadError: '',
|
||||
htmlHint: null,
|
||||
subTab: 'operations',
|
||||
serverIndex: 0,
|
||||
@@ -432,7 +430,6 @@ export const useSJStore = create<SJState>((set, get) => ({
|
||||
return patchTab(s, tabId, {
|
||||
spec: normalized,
|
||||
loading: false,
|
||||
loadError: '',
|
||||
htmlHint: null,
|
||||
name: normalized.title || 'Untitled',
|
||||
serverIndex: 0,
|
||||
|
||||
Reference in new issue
Block a user