@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.

Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/client.js +434 -412
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -110
  7. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  8. package/src/cortex-ui/renderer/App.tsx +39 -0
  9. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  10. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  11. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  12. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  13. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  14. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
  16. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  17. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  18. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  19. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  20. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  21. package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
  22. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  23. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  24. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  25. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  26. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  27. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
  28. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  29. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  30. package/src/host/cortex/index.js +42 -1
  31. package/src/host/cortex/repeater-store.js +411 -0
  32. package/src/host/cortex/repeater.js +15 -2
  33. package/src/host/cortex/tools.js +2 -0
  34. package/src/host/miniapp/analyze.js +1078 -0
  35. package/src/host/miniapp/assets-import.js +294 -0
  36. package/src/host/miniapp/detect.js +426 -0
  37. package/src/host/miniapp/format.js +263 -0
  38. package/src/host/miniapp/roots.js +360 -0
  39. package/src/host/miniapp/routes.js +304 -0
  40. package/src/host/miniapp/service.js +559 -0
  41. package/src/host/miniapp/store.js +756 -0
  42. package/src/host/miniapp/tools.js +335 -0
  43. package/src/host/miniapp/unpack.js +1010 -0
  44. package/src/host/miniapp/wxapkg.js +416 -0
@@ -1,70 +1,51 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
- import { ArrowRight, CornerDownRight, Loader2, Play } from 'lucide-react'
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 { prettyJsonBody } from '@renderer/lib/retestResponse'
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
- function headerValue(headers: Record<string, string>, name: string): string {
79
- const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name)
80
- return entry?.[1] ?? ''
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
- function isRedirectResponse(result: RepeaterSendResult): boolean {
84
- const status = result.response.status
85
- return status >= 300 && status < 400 && headerValue(result.response.headers, 'location') !== ''
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
- function isHtmlResponse(result: RepeaterSendResult): boolean {
89
- const contentType = headerValue(result.response.headers, 'content-type')
90
- if (/text\/html|application\/xhtml\+xml/i.test(contentType)) return true
91
- return /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(result.response.body.slice(0, 300))
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
- /** Rendered HTML needs a base URL so relative CSS/images resolve against the target. */
95
- function buildHtmlPreview(body: string, targetUrl: string): string {
96
- let base = ''
97
- try {
98
- base = `${new URL(targetUrl).origin}/`
99
- } catch {
100
- base = ''
101
- }
102
- if (base === '') return body
103
- const tag = `<base href="${base.replace(/"/g, '&quot;')}">`
104
- if (/<head(?:\s[^>]*)?>/i.test(body)) {
105
- return body.replace(/<head(?:\s[^>]*)?>/i, (match) => `${match}${tag}`)
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
- return `${tag}${body}`
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
- function statusTone(status: number): string {
111
- if (status >= 500) return 'border-red-300 bg-red-50 text-red-800'
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
- * Manual HTTP repeater. Requests are extracted from the finding's Markdown
119
- * report (all HTTP request steps are selectable) instead of the old generated-JS
120
- * pipeline. The user edits the raw request and sends it through the retest proxy.
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
- export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
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(() => (vuln === null ? [] : extractRepeaterRequests(vuln)), [vuln])
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
- setRaw(requests[nextStep]?.raw ?? '')
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?.id])
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 = result !== null && isRedirectResponse(result)
159
- const responseIsHtml = result !== null && isHtmlResponse(result)
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 || vuln === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
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 (vuln === null || sending) return
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
- setBodyView('raw')
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
- if (loading && vuln === null) {
205
- return (
206
- <div className="flex h-full flex-col gap-3">
207
- <Skeleton className="h-[310px] w-full" />
208
- <Skeleton className="h-[220px] w-full" />
209
- </div>
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
- if (vuln === null) {
214
- return (
215
- <div className="flex h-full items-center justify-center rounded-lg border border-dashed border-border bg-background/60 text-[12px] text-muted-foreground">
216
- {t('retest.repeater.empty')}
217
- </div>
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
- <div className="flex min-h-0 flex-1 flex-col gap-3">
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">{t('retest.repeater.title')}</span>
226
- <span className="rounded border border-border bg-sidebar px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground">
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 className="ml-auto flex max-w-full items-center gap-1 overflow-x-auto" role="tablist" aria-label={t('retest.repeater.stepsAria')}>
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-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-[hsl(220_14%_92%)] caret-[hsl(174_77%_45%)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
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 role="alert" 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">
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">{t('retest.repeater.responseTitle')}</span>
422
+ <span className="text-[12px] font-medium text-foreground">
423
+ {t('retest.repeater.responseTitle')}
424
+ </span>
293
425
  {result !== null ? (
294
426
  <>
295
- <span className={`rounded border px-1.5 py-0.5 font-mono text-[10px] tabular-nums ${statusTone(result.response.status)}`}>
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">{t('retest.repeater.truncated')}</span>
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 overflow-auto p-3">
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="h-[min(60vh,520px)] min-h-[320px] w-full rounded-md border border-border bg-white"
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-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11px] leading-relaxed text-[hsl(220_14%_92%)]"
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
- {formattedJson}
519
+ {jsonResponse}
389
520
  </pre>
390
521
  ) : (
391
- <pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11px] leading-relaxed text-[hsl(220_14%_92%)]">
392
- {result.response.body.slice(0, 200_000)}
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
- </div>
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
  }