@lievclouds/dsh-cortex 0.5.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.
- package/README.md +1 -1
- package/client.js +434 -412
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -110
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/host/cortex/index.js +42 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/tools.js +2 -0
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
|
@@ -1,70 +1,51 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ArrowRight,
|
|
4
|
+
Braces,
|
|
5
|
+
Clock,
|
|
6
|
+
CornerDownRight,
|
|
7
|
+
FileInput,
|
|
8
|
+
FileOutput,
|
|
9
|
+
Loader2,
|
|
10
|
+
Play,
|
|
11
|
+
Star
|
|
12
|
+
} from 'lucide-react'
|
|
13
|
+
import { toast } from 'sonner'
|
|
3
14
|
import type { Vulnerability } from '@shared/types'
|
|
4
15
|
import type { RepeaterSendResult } from '@shared/repeater'
|
|
5
16
|
import { Button } from '@renderer/components/ui/button'
|
|
6
17
|
import { Skeleton } from '@renderer/components/ui/skeleton'
|
|
18
|
+
import { useCopy } from '@renderer/hooks/useCopy'
|
|
7
19
|
import { useI18n } from '@renderer/i18n'
|
|
8
20
|
import { getApi } from '@renderer/lib/api'
|
|
21
|
+
import { buildCurlCommand } from '@renderer/lib/curl'
|
|
22
|
+
import {
|
|
23
|
+
formatRequestBody,
|
|
24
|
+
parseRawRequest,
|
|
25
|
+
stripDerivedHeaders
|
|
26
|
+
} from '@renderer/lib/repeaterRaw'
|
|
9
27
|
import {
|
|
10
28
|
defaultRequestIndex,
|
|
11
29
|
extractRepeaterRequests,
|
|
12
30
|
resolveRepeaterUrl
|
|
13
31
|
} from '@renderer/lib/retestRequest'
|
|
14
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
buildHtmlPreview,
|
|
34
|
+
headerValue,
|
|
35
|
+
isHtmlResponse,
|
|
36
|
+
isRedirectResponse,
|
|
37
|
+
prettyJsonBody,
|
|
38
|
+
statusTone
|
|
39
|
+
} from '@renderer/lib/retestResponse'
|
|
40
|
+
import { CurlImportDialog } from './CurlImportDialog'
|
|
41
|
+
import { RepeaterCollectionsDialog } from './RepeaterCollectionsDialog'
|
|
42
|
+
import { RepeaterHistoryDialog } from './RepeaterHistoryDialog'
|
|
15
43
|
|
|
16
44
|
interface RepeaterPanelProps {
|
|
17
45
|
vuln: Vulnerability | null
|
|
18
46
|
loading: boolean
|
|
19
47
|
}
|
|
20
48
|
|
|
21
|
-
interface ParsedRawRequest {
|
|
22
|
-
method: string
|
|
23
|
-
url: string
|
|
24
|
-
headers: Record<string, string>
|
|
25
|
-
body: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Protocol-level headers copied from a report are re-derived by the outbound
|
|
30
|
-
* request (Host from the URL, length/connection by the HTTP client). Dropping
|
|
31
|
-
* them here keeps raw requests sendable without a validation error.
|
|
32
|
-
*/
|
|
33
|
-
const DERIVED_HEADERS = new Set([
|
|
34
|
-
'host',
|
|
35
|
-
'content-length',
|
|
36
|
-
'connection',
|
|
37
|
-
'transfer-encoding',
|
|
38
|
-
'upgrade',
|
|
39
|
-
'keep-alive',
|
|
40
|
-
'proxy-connection'
|
|
41
|
-
])
|
|
42
|
-
|
|
43
|
-
/** Parse a Burp-like raw request into the typed repeater payload. */
|
|
44
|
-
function parseRawRequest(text: string): ParsedRawRequest {
|
|
45
|
-
const lines = text.replace(/\r\n/g, '\n').split('\n')
|
|
46
|
-
const requestLine = (lines.shift() ?? '').trim()
|
|
47
|
-
const match = /^([A-Za-z]+)\s+(\S+)\s+HTTP\/\d(?:\.\d)?$/.exec(requestLine)
|
|
48
|
-
if (match === null) throw new Error('请求行必须形如:GET /path HTTP/1.1')
|
|
49
|
-
const headers: Record<string, string> = {}
|
|
50
|
-
let index = 0
|
|
51
|
-
for (; index < lines.length; index += 1) {
|
|
52
|
-
const line = lines[index]
|
|
53
|
-
if (line === undefined) break
|
|
54
|
-
if (line.trim() === '') {
|
|
55
|
-
index += 1
|
|
56
|
-
break
|
|
57
|
-
}
|
|
58
|
-
const colon = line.indexOf(':')
|
|
59
|
-
if (colon <= 0) throw new Error(`请求头不合法:${line}`)
|
|
60
|
-
const name = line.slice(0, colon).trim()
|
|
61
|
-
const value = line.slice(colon + 1).trim()
|
|
62
|
-
if (name !== '') headers[name] = value
|
|
63
|
-
}
|
|
64
|
-
const body = lines.slice(index).join('\n')
|
|
65
|
-
return { method: match[1].toUpperCase(), url: match[2], headers, body }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
49
|
function describeSendError(cause: unknown, connectionRefused: string): string {
|
|
69
50
|
const raw = cause instanceof Error ? cause.message : String(cause)
|
|
70
51
|
const cleaned = raw
|
|
@@ -75,88 +56,123 @@ function describeSendError(cause: unknown, connectionRefused: string): string {
|
|
|
75
56
|
return cleaned === '' ? connectionRefused : cleaned
|
|
76
57
|
}
|
|
77
58
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
59
|
+
/** Saved history/collection rows share the raw + request + response shape. */
|
|
60
|
+
interface SavedRepeaterEntry {
|
|
61
|
+
raw: string
|
|
62
|
+
request: RepeaterSendResult['request']
|
|
63
|
+
response: RepeaterSendResult['response'] | null
|
|
81
64
|
}
|
|
82
65
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Manual HTTP repeater. Requests are extracted from the finding's Markdown
|
|
68
|
+
* report (all HTTP request steps are selectable) instead of the old generated-JS
|
|
69
|
+
* pipeline. The user edits the raw request and sends it through the retest proxy.
|
|
70
|
+
*
|
|
71
|
+
* The Yakit-style manual sender lives on its own Web Fuzzer page; this panel
|
|
72
|
+
* stays the per-finding single-request repeater (plus history, favorites and
|
|
73
|
+
* curl tools).
|
|
74
|
+
*/
|
|
75
|
+
export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
76
|
+
const { t } = useI18n()
|
|
87
77
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
78
|
+
const header = (
|
|
79
|
+
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
|
80
|
+
<span className="text-[12px] font-medium text-foreground">{t('retest.repeater.title')}</span>
|
|
81
|
+
{vuln !== null ? (
|
|
82
|
+
<span className="rounded border border-border bg-sidebar px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground">
|
|
83
|
+
{vuln.id}
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
93
88
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
89
|
+
if (vuln === null) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
92
|
+
{header}
|
|
93
|
+
{loading ? (
|
|
94
|
+
<>
|
|
95
|
+
<Skeleton className="h-[310px] w-full" />
|
|
96
|
+
<Skeleton className="h-[220px] w-full" />
|
|
97
|
+
</>
|
|
98
|
+
) : (
|
|
99
|
+
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-dashed border-border bg-background/60 text-[12px] text-muted-foreground">
|
|
100
|
+
{t('retest.repeater.empty')}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
106
105
|
}
|
|
107
|
-
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
|
109
|
+
{header}
|
|
110
|
+
<SingleRepeater vuln={vuln} />
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (status >= 400) return 'border-amber-300 bg-amber-50 text-amber-900'
|
|
113
|
-
if (status >= 300) return 'border-sky-300 bg-sky-50 text-sky-900'
|
|
114
|
-
return 'border-emerald-300 bg-emerald-50 text-emerald-900'
|
|
115
|
+
interface SingleRepeaterProps {
|
|
116
|
+
vuln: Vulnerability
|
|
115
117
|
}
|
|
116
118
|
|
|
117
119
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
120
|
+
* The original single Repeater. Behavior is intentionally unchanged: same
|
|
121
|
+
* request extraction/default step, retest-proxy send, redirect follow, raw /
|
|
122
|
+
* pretty / HTML views and byte counter. The toolbar gained read-only helpers
|
|
123
|
+
* (history, favorites, curl import/export) that never change how a send works.
|
|
121
124
|
*/
|
|
122
|
-
|
|
125
|
+
function SingleRepeater({ vuln }: SingleRepeaterProps) {
|
|
123
126
|
const { t } = useI18n()
|
|
127
|
+
const { copy } = useCopy()
|
|
124
128
|
const [raw, setRaw] = useState('')
|
|
125
129
|
const [step, setStep] = useState(0)
|
|
126
130
|
const [sending, setSending] = useState(false)
|
|
127
131
|
const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
|
|
128
132
|
const [error, setError] = useState<string | null>(null)
|
|
129
133
|
const [result, setResult] = useState<RepeaterSendResult | null>(null)
|
|
134
|
+
const [historyOpen, setHistoryOpen] = useState(false)
|
|
135
|
+
const [collectionsOpen, setCollectionsOpen] = useState(false)
|
|
136
|
+
const [curlImportOpen, setCurlImportOpen] = useState(false)
|
|
130
137
|
|
|
131
|
-
const requests = useMemo(() =>
|
|
138
|
+
const requests = useMemo(() => extractRepeaterRequests(vuln), [vuln])
|
|
132
139
|
|
|
133
140
|
useEffect(() => {
|
|
134
141
|
setResult(null)
|
|
135
142
|
setError(null)
|
|
136
|
-
if (vuln === null) {
|
|
137
|
-
setRaw('')
|
|
138
|
-
setStep(0)
|
|
139
|
-
return
|
|
140
|
-
}
|
|
141
143
|
const nextStep = defaultRequestIndex(vuln, requests)
|
|
142
144
|
setStep(nextStep)
|
|
143
|
-
|
|
145
|
+
const initial = requests[nextStep]?.raw ?? ''
|
|
146
|
+
setRaw(formatRequestBody(initial) ?? initial)
|
|
144
147
|
// Reset only when the selected vulnerability changes; user edits must survive.
|
|
145
148
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
146
|
-
}, [vuln
|
|
149
|
+
}, [vuln.id])
|
|
147
150
|
|
|
148
151
|
const selectStep = (nextStep: number): void => {
|
|
149
152
|
const next = requests[nextStep]
|
|
150
153
|
if (next === undefined) return
|
|
151
154
|
setStep(nextStep)
|
|
152
|
-
setRaw(next.raw)
|
|
155
|
+
setRaw(formatRequestBody(next.raw) ?? next.raw)
|
|
153
156
|
setResult(null)
|
|
154
157
|
setError(null)
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
/** Pretty-print the JSON request body while keeping the request line/headers. */
|
|
161
|
+
const formatRequest = (): void => {
|
|
162
|
+
const formatted = formatRequestBody(raw)
|
|
163
|
+
if (formatted === null) {
|
|
164
|
+
setError(t('retest.repeater.formatRequestFailed'))
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
setRaw(formatted)
|
|
168
|
+
setError(null)
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
|
|
158
|
-
const responseIsRedirect =
|
|
159
|
-
|
|
172
|
+
const responseIsRedirect =
|
|
173
|
+
result !== null && isRedirectResponse(result.response.status, result.response.headers)
|
|
174
|
+
const responseIsHtml =
|
|
175
|
+
result !== null && isHtmlResponse(result.response.body, result.response.headers)
|
|
160
176
|
const formattedJson = useMemo(
|
|
161
177
|
() =>
|
|
162
178
|
result === null
|
|
@@ -165,12 +181,55 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
165
181
|
[result]
|
|
166
182
|
)
|
|
167
183
|
const htmlPreview = useMemo(
|
|
168
|
-
() => (result === null
|
|
169
|
-
[result, vuln]
|
|
184
|
+
() => (result === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
|
|
185
|
+
[result, vuln.target_url]
|
|
170
186
|
)
|
|
187
|
+
/** Status line + response headers, shared by the raw and formatted views. */
|
|
188
|
+
const responseHead = useMemo(() => {
|
|
189
|
+
if (result === null) return ''
|
|
190
|
+
const lines = [`HTTP/1.1 ${result.response.status}`]
|
|
191
|
+
for (const [name, value] of Object.entries(result.response.headers)) {
|
|
192
|
+
lines.push(`${name}: ${value}`)
|
|
193
|
+
}
|
|
194
|
+
return lines.join('\n')
|
|
195
|
+
}, [result])
|
|
196
|
+
/** Raw response as one block: status + headers + blank line + body. */
|
|
197
|
+
const rawResponse = useMemo(
|
|
198
|
+
() => (result === null ? '' : `${responseHead}\n\n${result.response.body}`),
|
|
199
|
+
[result, responseHead]
|
|
200
|
+
)
|
|
201
|
+
/** JSON view keeps the status/header block and only formats the body. */
|
|
202
|
+
const jsonResponse = useMemo(
|
|
203
|
+
() => (formattedJson === null ? '' : `${responseHead}\n\n${formattedJson}`),
|
|
204
|
+
[formattedJson, responseHead]
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
/** Parsed request used by the favorites dialog (null while the editor is invalid). */
|
|
208
|
+
const currentRequest = useMemo<RepeaterSendResult['request'] | null>(() => {
|
|
209
|
+
try {
|
|
210
|
+
const parsed = parseRawRequest(raw)
|
|
211
|
+
return {
|
|
212
|
+
method: parsed.method,
|
|
213
|
+
url: resolveRepeaterUrl(parsed.url, vuln.target_url),
|
|
214
|
+
headers: stripDerivedHeaders(parsed.headers),
|
|
215
|
+
body: parsed.body
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
}, [raw, vuln.target_url])
|
|
221
|
+
|
|
222
|
+
const restoreSaved = (item: SavedRepeaterEntry): void => {
|
|
223
|
+
setRaw(item.raw)
|
|
224
|
+
setError(null)
|
|
225
|
+
setBodyView('raw')
|
|
226
|
+
setResult(
|
|
227
|
+
item.response === null ? null : { request: item.request, response: item.response }
|
|
228
|
+
)
|
|
229
|
+
}
|
|
171
230
|
|
|
172
231
|
const send = async (followRedirects = false): Promise<void> => {
|
|
173
|
-
if (
|
|
232
|
+
if (sending) return
|
|
174
233
|
const api = getApi()
|
|
175
234
|
if (!api) {
|
|
176
235
|
setError(t('common.apiUnavailable'))
|
|
@@ -180,19 +239,21 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
180
239
|
setError(null)
|
|
181
240
|
try {
|
|
182
241
|
const parsed = parseRawRequest(raw)
|
|
183
|
-
const headers = Object.fromEntries(
|
|
184
|
-
Object.entries(parsed.headers).filter(([name]) => !DERIVED_HEADERS.has(name.toLowerCase()))
|
|
185
|
-
)
|
|
186
242
|
const response = await api.sendRepeaterRequest({
|
|
187
243
|
vulnId: vuln.id,
|
|
188
244
|
method: parsed.method,
|
|
189
245
|
url: resolveRepeaterUrl(parsed.url, vuln.target_url),
|
|
190
|
-
headers,
|
|
246
|
+
headers: stripDerivedHeaders(parsed.headers),
|
|
191
247
|
...(parsed.body === '' ? {} : { body: parsed.body }),
|
|
192
248
|
followRedirects
|
|
193
249
|
})
|
|
194
250
|
setResult(response)
|
|
195
|
-
|
|
251
|
+
// Formatted JSON is the default response view; raw stays one click away.
|
|
252
|
+
setBodyView(
|
|
253
|
+
prettyJsonBody(response.response.body, headerValue(response.response.headers, 'content-type')) !== null
|
|
254
|
+
? 'pretty'
|
|
255
|
+
: 'raw'
|
|
256
|
+
)
|
|
196
257
|
} catch (cause) {
|
|
197
258
|
setError(describeSendError(cause, t('retest.repeater.connectionFailed')))
|
|
198
259
|
setResult(null)
|
|
@@ -201,33 +262,44 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
201
262
|
}
|
|
202
263
|
}
|
|
203
264
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
265
|
+
const exportCurl = async (): Promise<void> => {
|
|
266
|
+
let command: string
|
|
267
|
+
try {
|
|
268
|
+
const parsed = parseRawRequest(raw)
|
|
269
|
+
command = buildCurlCommand({
|
|
270
|
+
method: parsed.method,
|
|
271
|
+
url: resolveRepeaterUrl(parsed.url, vuln.target_url),
|
|
272
|
+
headers: stripDerivedHeaders(parsed.headers),
|
|
273
|
+
body: parsed.body
|
|
274
|
+
})
|
|
275
|
+
} catch {
|
|
276
|
+
toast.error(t('retest.repeater.curl.parseFailed'))
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
// The toast only says "copied"; the command (cookies included) is never shown.
|
|
280
|
+
await copy(command, t('retest.repeater.curl.exported'))
|
|
211
281
|
}
|
|
212
282
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
)
|
|
283
|
+
const applyCurl = (nextRaw: string): void => {
|
|
284
|
+
setRaw(nextRaw)
|
|
285
|
+
setResult(null)
|
|
286
|
+
setError(null)
|
|
287
|
+
setBodyView('raw')
|
|
219
288
|
}
|
|
220
289
|
|
|
221
290
|
return (
|
|
222
|
-
|
|
291
|
+
<>
|
|
223
292
|
<section className="rounded-lg border border-border bg-background">
|
|
224
293
|
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
|
225
|
-
<span className="text-[12px] font-medium text-foreground">
|
|
226
|
-
|
|
227
|
-
{vuln.id}
|
|
294
|
+
<span className="text-[12px] font-medium text-foreground">
|
|
295
|
+
{t('retest.repeater.requestSection')}
|
|
228
296
|
</span>
|
|
229
297
|
{requests.length > 0 ? (
|
|
230
|
-
<div
|
|
298
|
+
<div
|
|
299
|
+
className="ml-auto flex max-w-full items-center gap-1 overflow-x-auto"
|
|
300
|
+
role="tablist"
|
|
301
|
+
aria-label={t('retest.repeater.stepsAria')}
|
|
302
|
+
>
|
|
231
303
|
{requests.map((request, index) => (
|
|
232
304
|
<button
|
|
233
305
|
key={`${index}-${request.label}`}
|
|
@@ -256,7 +328,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
256
328
|
value={raw}
|
|
257
329
|
spellCheck={false}
|
|
258
330
|
onChange={(event) => setRaw(event.target.value)}
|
|
259
|
-
className="min-h-[210px] w-full resize-y rounded-lg border border-border bg-
|
|
331
|
+
className="min-h-[210px] w-full resize-y rounded-lg border border-border bg-white px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-foreground caret-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
260
332
|
/>
|
|
261
333
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
262
334
|
<Button
|
|
@@ -274,12 +346,70 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
274
346
|
{sending ? t('retest.repeater.sending') : t('retest.repeater.send')}
|
|
275
347
|
</Button>
|
|
276
348
|
|
|
349
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
350
|
+
<Button
|
|
351
|
+
variant="outline"
|
|
352
|
+
size="xs"
|
|
353
|
+
data-testid="retest-repeater-format"
|
|
354
|
+
title={t('retest.repeater.formatRequestTitle')}
|
|
355
|
+
onClick={formatRequest}
|
|
356
|
+
className="gap-1 text-[10.5px]"
|
|
357
|
+
>
|
|
358
|
+
<Braces aria-hidden="true" className="h-3 w-3" />
|
|
359
|
+
{t('retest.repeater.formatRequest')}
|
|
360
|
+
</Button>
|
|
361
|
+
<Button
|
|
362
|
+
variant="outline"
|
|
363
|
+
size="xs"
|
|
364
|
+
data-testid="repeater-history-open"
|
|
365
|
+
onClick={() => setHistoryOpen(true)}
|
|
366
|
+
className="gap-1 text-[10.5px]"
|
|
367
|
+
>
|
|
368
|
+
<Clock aria-hidden="true" className="h-3 w-3" />
|
|
369
|
+
{t('retest.repeater.history.open')}
|
|
370
|
+
</Button>
|
|
371
|
+
<Button
|
|
372
|
+
variant="outline"
|
|
373
|
+
size="xs"
|
|
374
|
+
data-testid="repeater-collections-open"
|
|
375
|
+
onClick={() => setCollectionsOpen(true)}
|
|
376
|
+
className="gap-1 text-[10.5px]"
|
|
377
|
+
>
|
|
378
|
+
<Star aria-hidden="true" className="h-3 w-3" />
|
|
379
|
+
{t('retest.repeater.favorites.open')}
|
|
380
|
+
</Button>
|
|
381
|
+
<Button
|
|
382
|
+
variant="outline"
|
|
383
|
+
size="xs"
|
|
384
|
+
data-testid="repeater-curl-import"
|
|
385
|
+
onClick={() => setCurlImportOpen(true)}
|
|
386
|
+
className="gap-1 text-[10.5px]"
|
|
387
|
+
>
|
|
388
|
+
<FileInput aria-hidden="true" className="h-3 w-3" />
|
|
389
|
+
{t('retest.repeater.curl.import')}
|
|
390
|
+
</Button>
|
|
391
|
+
<Button
|
|
392
|
+
variant="outline"
|
|
393
|
+
size="xs"
|
|
394
|
+
data-testid="repeater-curl-export"
|
|
395
|
+
disabled={raw.trim() === ''}
|
|
396
|
+
onClick={() => void exportCurl()}
|
|
397
|
+
className="gap-1 text-[10.5px]"
|
|
398
|
+
>
|
|
399
|
+
<FileOutput aria-hidden="true" className="h-3 w-3" />
|
|
400
|
+
{t('retest.repeater.curl.export')}
|
|
401
|
+
</Button>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
277
404
|
<span className="ml-auto font-mono text-[9.5px] tabular-nums text-muted-foreground">
|
|
278
405
|
{t('retest.repeater.bytes', { count: rawBytes })}
|
|
279
406
|
</span>
|
|
280
407
|
</div>
|
|
281
408
|
{error !== null ? (
|
|
282
|
-
<p
|
|
409
|
+
<p
|
|
410
|
+
role="alert"
|
|
411
|
+
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2.5 py-1.5 text-[10.5px] leading-relaxed text-red-700"
|
|
412
|
+
>
|
|
283
413
|
{error}
|
|
284
414
|
</p>
|
|
285
415
|
) : null}
|
|
@@ -289,17 +419,23 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
289
419
|
<section className="flex min-h-0 flex-1 flex-col rounded-lg border border-border bg-background">
|
|
290
420
|
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
|
291
421
|
<ArrowRight aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
|
|
292
|
-
<span className="text-[12px] font-medium text-foreground">
|
|
422
|
+
<span className="text-[12px] font-medium text-foreground">
|
|
423
|
+
{t('retest.repeater.responseTitle')}
|
|
424
|
+
</span>
|
|
293
425
|
{result !== null ? (
|
|
294
426
|
<>
|
|
295
|
-
<span
|
|
427
|
+
<span
|
|
428
|
+
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] tabular-nums ${statusTone(result.response.status)}`}
|
|
429
|
+
>
|
|
296
430
|
{result.response.status}
|
|
297
431
|
</span>
|
|
298
432
|
<span className="font-mono text-[9.5px] tabular-nums text-muted-foreground">
|
|
299
433
|
{result.response.durationMs} ms · {result.response.body.length} B
|
|
300
434
|
</span>
|
|
301
435
|
{result.response.truncated ? (
|
|
302
|
-
<span className="text-[9.5px] text-amber-700">
|
|
436
|
+
<span className="text-[9.5px] text-amber-700">
|
|
437
|
+
{t('retest.repeater.truncated')}
|
|
438
|
+
</span>
|
|
303
439
|
) : null}
|
|
304
440
|
{responseIsRedirect ? (
|
|
305
441
|
<Button
|
|
@@ -366,35 +502,53 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
366
502
|
{t('retest.repeater.emptyResponse')}
|
|
367
503
|
</p>
|
|
368
504
|
) : (
|
|
369
|
-
<div className="min-h-0 flex-1
|
|
370
|
-
<pre className="mb-2 whitespace-pre-wrap break-words rounded-md border border-border bg-sidebar px-2.5 py-2 font-mono text-[10.5px] leading-relaxed text-foreground">
|
|
371
|
-
{Object.entries(result.response.headers)
|
|
372
|
-
.map(([name, value]) => `${name}: ${value}`)
|
|
373
|
-
.join('\n')}
|
|
374
|
-
</pre>
|
|
505
|
+
<div className="flex min-h-0 flex-1 flex-col p-3">
|
|
375
506
|
{responseIsHtml && bodyView === 'html' ? (
|
|
376
507
|
<iframe
|
|
377
508
|
title={t('retest.repeater.htmlPreview')}
|
|
378
509
|
sandbox=""
|
|
379
510
|
referrerPolicy="no-referrer"
|
|
380
511
|
srcDoc={htmlPreview}
|
|
381
|
-
className="
|
|
512
|
+
className="min-h-0 w-full flex-1 rounded-md border border-border bg-white"
|
|
382
513
|
/>
|
|
383
514
|
) : formattedJson !== null && bodyView === 'pretty' ? (
|
|
384
515
|
<pre
|
|
385
516
|
data-testid="retest-repeater-json"
|
|
386
|
-
className="whitespace-pre-wrap break-words rounded-md border border-border bg-
|
|
517
|
+
className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-white px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-foreground"
|
|
387
518
|
>
|
|
388
|
-
{
|
|
519
|
+
{jsonResponse}
|
|
389
520
|
</pre>
|
|
390
521
|
) : (
|
|
391
|
-
<pre
|
|
392
|
-
|
|
522
|
+
<pre
|
|
523
|
+
data-testid="retest-repeater-response"
|
|
524
|
+
className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-white px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-foreground"
|
|
525
|
+
>
|
|
526
|
+
{rawResponse.slice(0, 200_000)}
|
|
393
527
|
</pre>
|
|
394
528
|
)}
|
|
395
529
|
</div>
|
|
396
530
|
)}
|
|
397
531
|
</section>
|
|
398
|
-
|
|
532
|
+
|
|
533
|
+
<RepeaterHistoryDialog
|
|
534
|
+
open={historyOpen}
|
|
535
|
+
onOpenChange={setHistoryOpen}
|
|
536
|
+
onRestore={restoreSaved}
|
|
537
|
+
/>
|
|
538
|
+
<RepeaterCollectionsDialog
|
|
539
|
+
open={collectionsOpen}
|
|
540
|
+
vulnId={vuln.id}
|
|
541
|
+
raw={raw}
|
|
542
|
+
request={currentRequest}
|
|
543
|
+
response={result === null ? null : result.response}
|
|
544
|
+
onOpenChange={setCollectionsOpen}
|
|
545
|
+
onLoad={restoreSaved}
|
|
546
|
+
/>
|
|
547
|
+
<CurlImportDialog
|
|
548
|
+
open={curlImportOpen}
|
|
549
|
+
onOpenChange={setCurlImportOpen}
|
|
550
|
+
onApply={applyCurl}
|
|
551
|
+
/>
|
|
552
|
+
</>
|
|
399
553
|
)
|
|
400
554
|
}
|