Compare commits

...
10 Commits
23 changed files with 705 additions and 102 deletions

No files matched your search

+2
View File
@@ -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 (
+15
View File
@@ -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})
}
+35 -14
View File
@@ -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"`
+1
View File
@@ -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)
+42
View File
@@ -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.
+9 -3
View File
@@ -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
+14
View File
@@ -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.
+102
View File
@@ -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)}
}
+32
View File
@@ -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()
+1 -1
View File
@@ -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() {
+55
View File
@@ -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>
)
}
+79 -35
View File
@@ -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>&middot;</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>&middot;</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 -6
View File
@@ -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"
+3 -10
View File
@@ -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)}>
+2 -9
View File
@@ -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>
)}
+4
View File
@@ -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
+81 -7
View File
@@ -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)}`
}
+161
View File
@@ -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
View File
@@ -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">&mdash;</span>}
</td>
<td className="px-2 py-1">
<div className="flex flex-wrap gap-1">
{e.sinks.slice(0, 4).map((s, i) => (
+20 -3
View File
@@ -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) },
]
: []),
]}
/>
)}
+7
View File
@@ -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
View File
@@ -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}
-3
View File
@@ -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,