@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.
- package/README.md +1 -1
- package/client.js +428 -406
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -106
- package/src/cortex-ui/dsh/api.ts +5 -2
- 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/asset/AssetDetail.tsx +9 -1
- 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 +333 -148
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
- 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 +146 -2
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
- 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 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/cortex-ui/shared/ipc.ts +3 -4
- package/src/cortex-ui/shared/types.ts +9 -0
- package/src/host/cortex/fs.js +72 -0
- package/src/host/cortex/index.js +49 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/settings.js +54 -0
- package/src/host/cortex/tools.js +2 -0
- package/src/host/cortex/vulns.js +36 -13
- 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
- package/src/host/reports-office.js +341 -0
|
@@ -1,69 +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'
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
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 =
|
|
158
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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">
|
|
218
|
-
|
|
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
|
|
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-
|
|
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
|
|
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">
|
|
422
|
+
<span className="text-[12px] font-medium text-foreground">
|
|
423
|
+
{t('retest.repeater.responseTitle')}
|
|
424
|
+
</span>
|
|
285
425
|
{result !== null ? (
|
|
286
426
|
<>
|
|
287
|
-
<span
|
|
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">
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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="
|
|
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
|
|
361
|
-
|
|
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
|
-
|
|
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
|
}
|