@lievclouds/dsh-cortex 0.4.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 (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  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 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -0
@@ -1,69 +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'
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'
14
43
 
15
44
  interface RepeaterPanelProps {
16
45
  vuln: Vulnerability | null
17
46
  loading: boolean
18
47
  }
19
48
 
20
- interface ParsedRawRequest {
21
- method: string
22
- url: string
23
- headers: Record<string, string>
24
- body: string
25
- }
26
-
27
- /**
28
- * Protocol-level headers copied from a report are re-derived by the outbound
29
- * request (Host from the URL, length/connection by the HTTP client). Dropping
30
- * them here keeps raw requests sendable without a validation error.
31
- */
32
- const DERIVED_HEADERS = new Set([
33
- 'host',
34
- 'content-length',
35
- 'connection',
36
- 'transfer-encoding',
37
- 'upgrade',
38
- 'keep-alive',
39
- 'proxy-connection'
40
- ])
41
-
42
- /** Parse a Burp-like raw request into the typed repeater payload. */
43
- function parseRawRequest(text: string): ParsedRawRequest {
44
- const lines = text.replace(/\r\n/g, '\n').split('\n')
45
- const requestLine = (lines.shift() ?? '').trim()
46
- const match = /^([A-Za-z]+)\s+(\S+)\s+HTTP\/\d(?:\.\d)?$/.exec(requestLine)
47
- if (match === null) throw new Error('请求行必须形如:GET /path HTTP/1.1')
48
- const headers: Record<string, string> = {}
49
- let index = 0
50
- for (; index < lines.length; index += 1) {
51
- const line = lines[index]
52
- if (line === undefined) break
53
- if (line.trim() === '') {
54
- index += 1
55
- break
56
- }
57
- const colon = line.indexOf(':')
58
- if (colon <= 0) throw new Error(`请求头不合法:${line}`)
59
- const name = line.slice(0, colon).trim()
60
- const value = line.slice(colon + 1).trim()
61
- if (name !== '') headers[name] = value
62
- }
63
- const body = lines.slice(index).join('\n')
64
- return { method: match[1].toUpperCase(), url: match[2], headers, body }
65
- }
66
-
67
49
  function describeSendError(cause: unknown, connectionRefused: string): string {
68
50
  const raw = cause instanceof Error ? cause.message : String(cause)
69
51
  const cleaned = raw
@@ -74,95 +56,180 @@ function describeSendError(cause: unknown, connectionRefused: string): string {
74
56
  return cleaned === '' ? connectionRefused : cleaned
75
57
  }
76
58
 
77
- function headerValue(headers: Record<string, string>, name: string): string {
78
- const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name)
79
- 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
80
64
  }
81
65
 
82
- function isRedirectResponse(result: RepeaterSendResult): boolean {
83
- const status = result.response.status
84
- return status >= 300 && status < 400 && headerValue(result.response.headers, 'location') !== ''
85
- }
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()
86
77
 
87
- function isHtmlResponse(result: RepeaterSendResult): boolean {
88
- const contentType = headerValue(result.response.headers, 'content-type')
89
- if (/text\/html|application\/xhtml\+xml/i.test(contentType)) return true
90
- return /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(result.response.body.slice(0, 300))
91
- }
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
+ )
92
88
 
93
- /** Rendered HTML needs a base URL so relative CSS/images resolve against the target. */
94
- function buildHtmlPreview(body: string, targetUrl: string): string {
95
- let base = ''
96
- try {
97
- base = `${new URL(targetUrl).origin}/`
98
- } catch {
99
- base = ''
100
- }
101
- if (base === '') return body
102
- const tag = `<base href="${base.replace(/"/g, '&quot;')}">`
103
- if (/<head(?:\s[^>]*)?>/i.test(body)) {
104
- 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
+ )
105
105
  }
106
- 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
+ )
107
113
  }
108
114
 
109
- function statusTone(status: number): string {
110
- if (status >= 500) return 'border-red-300 bg-red-50 text-red-800'
111
- if (status >= 400) return 'border-amber-300 bg-amber-50 text-amber-900'
112
- if (status >= 300) return 'border-sky-300 bg-sky-50 text-sky-900'
113
- return 'border-emerald-300 bg-emerald-50 text-emerald-900'
115
+ interface SingleRepeaterProps {
116
+ vuln: Vulnerability
114
117
  }
115
118
 
116
119
  /**
117
- * Manual HTTP repeater. Requests are extracted from the finding's Markdown
118
- * report (all HTTP request steps are selectable) instead of the old generated-JS
119
- * 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.
120
124
  */
121
- export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
125
+ function SingleRepeater({ vuln }: SingleRepeaterProps) {
122
126
  const { t } = useI18n()
127
+ const { copy } = useCopy()
123
128
  const [raw, setRaw] = useState('')
124
129
  const [step, setStep] = useState(0)
125
130
  const [sending, setSending] = useState(false)
126
- const [bodyView, setBodyView] = useState<'raw' | 'html'>('raw')
131
+ const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
127
132
  const [error, setError] = useState<string | null>(null)
128
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)
129
137
 
130
- const requests = useMemo(() => (vuln === null ? [] : extractRepeaterRequests(vuln)), [vuln])
138
+ const requests = useMemo(() => extractRepeaterRequests(vuln), [vuln])
131
139
 
132
140
  useEffect(() => {
133
141
  setResult(null)
134
142
  setError(null)
135
- if (vuln === null) {
136
- setRaw('')
137
- setStep(0)
138
- return
139
- }
140
143
  const nextStep = defaultRequestIndex(vuln, requests)
141
144
  setStep(nextStep)
142
- setRaw(requests[nextStep]?.raw ?? '')
145
+ const initial = requests[nextStep]?.raw ?? ''
146
+ setRaw(formatRequestBody(initial) ?? initial)
143
147
  // Reset only when the selected vulnerability changes; user edits must survive.
144
148
  // eslint-disable-next-line react-hooks/exhaustive-deps
145
- }, [vuln?.id])
149
+ }, [vuln.id])
146
150
 
147
151
  const selectStep = (nextStep: number): void => {
148
152
  const next = requests[nextStep]
149
153
  if (next === undefined) return
150
154
  setStep(nextStep)
151
- setRaw(next.raw)
155
+ setRaw(formatRequestBody(next.raw) ?? next.raw)
152
156
  setResult(null)
153
157
  setError(null)
154
158
  }
155
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
+
156
171
  const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
157
- const responseIsRedirect = result !== null && isRedirectResponse(result)
158
- 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)
176
+ const formattedJson = useMemo(
177
+ () =>
178
+ result === null
179
+ ? null
180
+ : prettyJsonBody(result.response.body, headerValue(result.response.headers, 'content-type')),
181
+ [result]
182
+ )
159
183
  const htmlPreview = useMemo(
160
- () => (result === null || vuln === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
161
- [result, vuln]
184
+ () => (result === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
185
+ [result, vuln.target_url]
162
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
+ }
163
230
 
164
231
  const send = async (followRedirects = false): Promise<void> => {
165
- if (vuln === null || sending) return
232
+ if (sending) return
166
233
  const api = getApi()
167
234
  if (!api) {
168
235
  setError(t('common.apiUnavailable'))
@@ -172,19 +239,21 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
172
239
  setError(null)
173
240
  try {
174
241
  const parsed = parseRawRequest(raw)
175
- const headers = Object.fromEntries(
176
- Object.entries(parsed.headers).filter(([name]) => !DERIVED_HEADERS.has(name.toLowerCase()))
177
- )
178
242
  const response = await api.sendRepeaterRequest({
179
243
  vulnId: vuln.id,
180
244
  method: parsed.method,
181
245
  url: resolveRepeaterUrl(parsed.url, vuln.target_url),
182
- headers,
246
+ headers: stripDerivedHeaders(parsed.headers),
183
247
  ...(parsed.body === '' ? {} : { body: parsed.body }),
184
248
  followRedirects
185
249
  })
186
250
  setResult(response)
187
- 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
+ )
188
257
  } catch (cause) {
189
258
  setError(describeSendError(cause, t('retest.repeater.connectionFailed')))
190
259
  setResult(null)
@@ -193,33 +262,44 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
193
262
  }
194
263
  }
195
264
 
196
- if (loading && vuln === null) {
197
- return (
198
- <div className="flex h-full flex-col gap-3">
199
- <Skeleton className="h-[310px] w-full" />
200
- <Skeleton className="h-[220px] w-full" />
201
- </div>
202
- )
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'))
203
281
  }
204
282
 
205
- if (vuln === null) {
206
- return (
207
- <div className="flex h-full items-center justify-center rounded-lg border border-dashed border-border bg-background/60 text-[12px] text-muted-foreground">
208
- {t('retest.repeater.empty')}
209
- </div>
210
- )
283
+ const applyCurl = (nextRaw: string): void => {
284
+ setRaw(nextRaw)
285
+ setResult(null)
286
+ setError(null)
287
+ setBodyView('raw')
211
288
  }
212
289
 
213
290
  return (
214
- <div className="flex min-h-0 flex-1 flex-col gap-3">
291
+ <>
215
292
  <section className="rounded-lg border border-border bg-background">
216
293
  <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
217
- <span className="text-[12px] font-medium text-foreground">{t('retest.repeater.title')}</span>
218
- <span className="rounded border border-border bg-sidebar px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground">
219
- {vuln.id}
294
+ <span className="text-[12px] font-medium text-foreground">
295
+ {t('retest.repeater.requestSection')}
220
296
  </span>
221
297
  {requests.length > 0 ? (
222
- <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
+ >
223
303
  {requests.map((request, index) => (
224
304
  <button
225
305
  key={`${index}-${request.label}`}
@@ -248,7 +328,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
248
328
  value={raw}
249
329
  spellCheck={false}
250
330
  onChange={(event) => setRaw(event.target.value)}
251
- 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"
252
332
  />
253
333
  <div className="mt-2 flex flex-wrap items-center gap-2">
254
334
  <Button
@@ -266,12 +346,70 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
266
346
  {sending ? t('retest.repeater.sending') : t('retest.repeater.send')}
267
347
  </Button>
268
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
+
269
404
  <span className="ml-auto font-mono text-[9.5px] tabular-nums text-muted-foreground">
270
405
  {t('retest.repeater.bytes', { count: rawBytes })}
271
406
  </span>
272
407
  </div>
273
408
  {error !== null ? (
274
- <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
+ >
275
413
  {error}
276
414
  </p>
277
415
  ) : null}
@@ -281,17 +419,23 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
281
419
  <section className="flex min-h-0 flex-1 flex-col rounded-lg border border-border bg-background">
282
420
  <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
283
421
  <ArrowRight aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
284
- <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>
285
425
  {result !== null ? (
286
426
  <>
287
- <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
+ >
288
430
  {result.response.status}
289
431
  </span>
290
432
  <span className="font-mono text-[9.5px] tabular-nums text-muted-foreground">
291
433
  {result.response.durationMs} ms · {result.response.body.length} B
292
434
  </span>
293
435
  {result.response.truncated ? (
294
- <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>
295
439
  ) : null}
296
440
  {responseIsRedirect ? (
297
441
  <Button
@@ -306,7 +450,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
306
450
  {t('retest.repeater.followRedirect')}
307
451
  </Button>
308
452
  ) : null}
309
- {responseIsHtml ? (
453
+ {formattedJson !== null || responseIsHtml ? (
310
454
  <div className="ml-auto flex items-center gap-0.5 rounded-md border border-border bg-muted p-0.5">
311
455
  <button
312
456
  type="button"
@@ -320,18 +464,34 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
320
464
  >
321
465
  {t('retest.repeater.viewRaw')}
322
466
  </button>
323
- <button
324
- type="button"
325
- data-testid="retest-repeater-view-html"
326
- onClick={() => setBodyView('html')}
327
- className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
328
- bodyView === 'html'
329
- ? 'bg-background font-medium text-foreground shadow-sm'
330
- : 'text-muted-foreground hover:text-foreground'
331
- }`}
332
- >
333
- {t('retest.repeater.viewHtml')}
334
- </button>
467
+ {formattedJson !== null ? (
468
+ <button
469
+ type="button"
470
+ data-testid="retest-repeater-view-json"
471
+ onClick={() => setBodyView('pretty')}
472
+ className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
473
+ bodyView === 'pretty'
474
+ ? 'bg-background font-medium text-foreground shadow-sm'
475
+ : 'text-muted-foreground hover:text-foreground'
476
+ }`}
477
+ >
478
+ {t('retest.repeater.formatJson')}
479
+ </button>
480
+ ) : null}
481
+ {responseIsHtml ? (
482
+ <button
483
+ type="button"
484
+ data-testid="retest-repeater-view-html"
485
+ onClick={() => setBodyView('html')}
486
+ className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
487
+ bodyView === 'html'
488
+ ? 'bg-background font-medium text-foreground shadow-sm'
489
+ : 'text-muted-foreground hover:text-foreground'
490
+ }`}
491
+ >
492
+ {t('retest.repeater.viewHtml')}
493
+ </button>
494
+ ) : null}
335
495
  </div>
336
496
  ) : null}
337
497
  </>
@@ -342,28 +502,53 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
342
502
  {t('retest.repeater.emptyResponse')}
343
503
  </p>
344
504
  ) : (
345
- <div className="min-h-0 flex-1 overflow-auto p-3">
346
- <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">
347
- {Object.entries(result.response.headers)
348
- .map(([name, value]) => `${name}: ${value}`)
349
- .join('\n')}
350
- </pre>
505
+ <div className="flex min-h-0 flex-1 flex-col p-3">
351
506
  {responseIsHtml && bodyView === 'html' ? (
352
507
  <iframe
353
508
  title={t('retest.repeater.htmlPreview')}
354
509
  sandbox=""
355
510
  referrerPolicy="no-referrer"
356
511
  srcDoc={htmlPreview}
357
- 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"
358
513
  />
514
+ ) : formattedJson !== null && bodyView === 'pretty' ? (
515
+ <pre
516
+ data-testid="retest-repeater-json"
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"
518
+ >
519
+ {jsonResponse}
520
+ </pre>
359
521
  ) : (
360
- <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%)]">
361
- {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)}
362
527
  </pre>
363
528
  )}
364
529
  </div>
365
530
  )}
366
531
  </section>
367
- </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
+ </>
368
553
  )
369
554
  }