@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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { ArrowRight, Braces, CornerDownRight, Loader2, Play } from 'lucide-react'
|
|
3
|
+
import type { Vulnerability } from '@shared/types'
|
|
4
|
+
import type { RepeaterSendResult } from '@shared/repeater'
|
|
5
|
+
import { Button } from '@renderer/components/ui/button'
|
|
6
|
+
import { Checkbox } from '@renderer/components/ui/checkbox'
|
|
7
|
+
import { Skeleton } from '@renderer/components/ui/skeleton'
|
|
8
|
+
import { useI18n } from '@renderer/i18n'
|
|
9
|
+
import { getApi } from '@renderer/lib/api'
|
|
10
|
+
import {
|
|
11
|
+
formatRequestBody,
|
|
12
|
+
parseRawRequest,
|
|
13
|
+
stripDerivedHeaders
|
|
14
|
+
} from '@renderer/lib/repeaterRaw'
|
|
15
|
+
import {
|
|
16
|
+
defaultRequestIndex,
|
|
17
|
+
extractRepeaterRequests,
|
|
18
|
+
resolveRepeaterUrl
|
|
19
|
+
} from '@renderer/lib/retestRequest'
|
|
20
|
+
import {
|
|
21
|
+
buildHtmlPreview,
|
|
22
|
+
headerValue,
|
|
23
|
+
isHtmlResponse,
|
|
24
|
+
isRedirectResponse,
|
|
25
|
+
prettyJsonBody,
|
|
26
|
+
statusTone
|
|
27
|
+
} from '@renderer/lib/retestResponse'
|
|
28
|
+
import { cn } from '@renderer/lib/utils'
|
|
29
|
+
|
|
30
|
+
interface YakitSendPanelProps {
|
|
31
|
+
vuln: Vulnerability | null
|
|
32
|
+
loading: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function describeSendError(cause: unknown, connectionRefused: string): string {
|
|
36
|
+
const raw = cause instanceof Error ? cause.message : String(cause)
|
|
37
|
+
const cleaned = raw
|
|
38
|
+
.replace(/^Error invoking remote method '[^']+':\s*/, '')
|
|
39
|
+
.replace(/^Error:\s*/, '')
|
|
40
|
+
.trim()
|
|
41
|
+
if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET/i.test(cleaned)) return connectionRefused
|
|
42
|
+
return cleaned === '' ? connectionRefused : cleaned
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Yakit-style manual HTTP sender: the request editor fills the left half with
|
|
47
|
+
* the send button at its top-left, the response viewer fills the right half, and
|
|
48
|
+
* both panes use the full available height. This is the plain Web Fuzzer module
|
|
49
|
+
* (no payload markers, attack modes, batch jobs or result table); every request
|
|
50
|
+
* still goes through the retest proxy and the finding origin allowlist.
|
|
51
|
+
*/
|
|
52
|
+
export function YakitSendPanel({ vuln, loading }: YakitSendPanelProps) {
|
|
53
|
+
const { t } = useI18n()
|
|
54
|
+
const [raw, setRaw] = useState('')
|
|
55
|
+
const [step, setStep] = useState(0)
|
|
56
|
+
const [sending, setSending] = useState(false)
|
|
57
|
+
const [followRedirects, setFollowRedirects] = useState(false)
|
|
58
|
+
const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
|
|
59
|
+
const [error, setError] = useState<string | null>(null)
|
|
60
|
+
const [result, setResult] = useState<RepeaterSendResult | null>(null)
|
|
61
|
+
|
|
62
|
+
const requests = useMemo(() => (vuln === null ? [] : extractRepeaterRequests(vuln)), [vuln])
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setResult(null)
|
|
66
|
+
setError(null)
|
|
67
|
+
if (vuln === null) {
|
|
68
|
+
setRaw('')
|
|
69
|
+
setStep(0)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const nextStep = defaultRequestIndex(vuln, requests)
|
|
73
|
+
setStep(nextStep)
|
|
74
|
+
const initial = requests[nextStep]?.raw ?? ''
|
|
75
|
+
setRaw(formatRequestBody(initial) ?? initial)
|
|
76
|
+
// Reset only when the selected vulnerability changes; user edits must survive.
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [vuln?.id])
|
|
79
|
+
|
|
80
|
+
const selectStep = (nextStep: number): void => {
|
|
81
|
+
const next = requests[nextStep]
|
|
82
|
+
if (next === undefined) return
|
|
83
|
+
setStep(nextStep)
|
|
84
|
+
setRaw(formatRequestBody(next.raw) ?? next.raw)
|
|
85
|
+
setResult(null)
|
|
86
|
+
setError(null)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Pretty-print the JSON request body while keeping the request line/headers. */
|
|
90
|
+
const formatRequest = (): void => {
|
|
91
|
+
const formatted = formatRequestBody(raw)
|
|
92
|
+
if (formatted === null) {
|
|
93
|
+
setError(t('retest.repeater.formatRequestFailed'))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
setRaw(formatted)
|
|
97
|
+
setError(null)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
|
|
101
|
+
const responseIsRedirect =
|
|
102
|
+
result !== null && isRedirectResponse(result.response.status, result.response.headers)
|
|
103
|
+
const responseIsHtml =
|
|
104
|
+
result !== null && isHtmlResponse(result.response.body, result.response.headers)
|
|
105
|
+
const formattedJson = useMemo(
|
|
106
|
+
() =>
|
|
107
|
+
result === null
|
|
108
|
+
? null
|
|
109
|
+
: prettyJsonBody(result.response.body, headerValue(result.response.headers, 'content-type')),
|
|
110
|
+
[result]
|
|
111
|
+
)
|
|
112
|
+
const htmlPreview = useMemo(
|
|
113
|
+
() =>
|
|
114
|
+
result === null || vuln === null
|
|
115
|
+
? ''
|
|
116
|
+
: buildHtmlPreview(result.response.body, vuln.target_url),
|
|
117
|
+
[result, vuln]
|
|
118
|
+
)
|
|
119
|
+
/** Status line + response headers, shared by the raw and formatted views. */
|
|
120
|
+
const responseHead = useMemo(() => {
|
|
121
|
+
if (result === null) return ''
|
|
122
|
+
const lines = [`HTTP/1.1 ${result.response.status}`]
|
|
123
|
+
for (const [name, value] of Object.entries(result.response.headers)) {
|
|
124
|
+
lines.push(`${name}: ${value}`)
|
|
125
|
+
}
|
|
126
|
+
return lines.join('\n')
|
|
127
|
+
}, [result])
|
|
128
|
+
/** Raw response as one block: status + headers + blank line + body. */
|
|
129
|
+
const rawResponse = useMemo(
|
|
130
|
+
() => (result === null ? '' : `${responseHead}\n\n${result.response.body}`),
|
|
131
|
+
[result, responseHead]
|
|
132
|
+
)
|
|
133
|
+
/** JSON view keeps the status/header block and only formats the body. */
|
|
134
|
+
const jsonResponse = useMemo(
|
|
135
|
+
() => (formattedJson === null ? '' : `${responseHead}\n\n${formattedJson}`),
|
|
136
|
+
[formattedJson, responseHead]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const send = async (): Promise<void> => {
|
|
140
|
+
if (vuln === null || sending) return
|
|
141
|
+
const api = getApi()
|
|
142
|
+
if (!api) {
|
|
143
|
+
setError(t('common.apiUnavailable'))
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
setSending(true)
|
|
147
|
+
setError(null)
|
|
148
|
+
try {
|
|
149
|
+
const parsed = parseRawRequest(raw)
|
|
150
|
+
const response = await api.sendRepeaterRequest({
|
|
151
|
+
vulnId: vuln.id,
|
|
152
|
+
method: parsed.method,
|
|
153
|
+
url: resolveRepeaterUrl(parsed.url, vuln.target_url),
|
|
154
|
+
headers: stripDerivedHeaders(parsed.headers),
|
|
155
|
+
...(parsed.body === '' ? {} : { body: parsed.body }),
|
|
156
|
+
followRedirects
|
|
157
|
+
})
|
|
158
|
+
setResult(response)
|
|
159
|
+
// Formatted JSON is the default response view; raw stays one click away.
|
|
160
|
+
setBodyView(
|
|
161
|
+
prettyJsonBody(response.response.body, headerValue(response.response.headers, 'content-type')) !== null
|
|
162
|
+
? 'pretty'
|
|
163
|
+
: 'raw'
|
|
164
|
+
)
|
|
165
|
+
} catch (cause) {
|
|
166
|
+
setError(describeSendError(cause, t('retest.repeater.connectionFailed')))
|
|
167
|
+
setResult(null)
|
|
168
|
+
} finally {
|
|
169
|
+
setSending(false)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (loading && vuln === null) {
|
|
174
|
+
return (
|
|
175
|
+
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
|
|
176
|
+
<Skeleton className="h-full min-h-[360px] w-full" />
|
|
177
|
+
<Skeleton className="h-full min-h-[360px] w-full" />
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (vuln === null) {
|
|
183
|
+
return (
|
|
184
|
+
<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">
|
|
185
|
+
{t('retest.repeater.empty')}
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
data-testid="fuzzer-panel"
|
|
193
|
+
className="grid min-h-0 flex-1 items-stretch gap-3 lg:grid-cols-2"
|
|
194
|
+
>
|
|
195
|
+
{/* ------------------------------------------------------------- request */}
|
|
196
|
+
<section
|
|
197
|
+
data-testid="fuzzer-request-pane"
|
|
198
|
+
aria-label={t('fuzzer.request.paneAria')}
|
|
199
|
+
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background"
|
|
200
|
+
>
|
|
201
|
+
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
|
|
202
|
+
<Button
|
|
203
|
+
size="sm"
|
|
204
|
+
data-testid="fuzzer-send"
|
|
205
|
+
onClick={() => void send()}
|
|
206
|
+
disabled={sending || raw.trim() === ''}
|
|
207
|
+
className="gap-1.5"
|
|
208
|
+
>
|
|
209
|
+
{sending ? (
|
|
210
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin poc-spinner" />
|
|
211
|
+
) : (
|
|
212
|
+
<Play aria-hidden="true" className="h-3.5 w-3.5" />
|
|
213
|
+
)}
|
|
214
|
+
{sending ? t('retest.repeater.sending') : t('retest.repeater.send')}
|
|
215
|
+
</Button>
|
|
216
|
+
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-[10.5px] text-muted-foreground">
|
|
217
|
+
<Checkbox
|
|
218
|
+
data-testid="fuzzer-follow-redirects"
|
|
219
|
+
checked={followRedirects}
|
|
220
|
+
onChange={(event) => setFollowRedirects(event.target.checked)}
|
|
221
|
+
/>
|
|
222
|
+
{t('retest.repeater.followRedirects')}
|
|
223
|
+
</label>
|
|
224
|
+
<Button
|
|
225
|
+
variant="outline"
|
|
226
|
+
size="xs"
|
|
227
|
+
data-testid="fuzzer-format-request"
|
|
228
|
+
title={t('retest.repeater.formatRequestTitle')}
|
|
229
|
+
onClick={formatRequest}
|
|
230
|
+
className="shrink-0 gap-1"
|
|
231
|
+
>
|
|
232
|
+
<Braces aria-hidden="true" className="h-3 w-3" />
|
|
233
|
+
{t('retest.repeater.formatRequest')}
|
|
234
|
+
</Button>
|
|
235
|
+
<span className="shrink-0 rounded border border-border bg-sidebar px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground">
|
|
236
|
+
{vuln.id}
|
|
237
|
+
</span>
|
|
238
|
+
{requests.length > 0 ? (
|
|
239
|
+
<div
|
|
240
|
+
className="ml-auto flex min-w-0 items-center gap-1 overflow-x-auto"
|
|
241
|
+
role="tablist"
|
|
242
|
+
aria-label={t('retest.repeater.stepsAria')}
|
|
243
|
+
>
|
|
244
|
+
{requests.map((request, index) => (
|
|
245
|
+
<button
|
|
246
|
+
key={`${index}-${request.label}`}
|
|
247
|
+
type="button"
|
|
248
|
+
role="tab"
|
|
249
|
+
aria-selected={index === step}
|
|
250
|
+
title={request.label}
|
|
251
|
+
data-testid={`fuzzer-request-step-${index}`}
|
|
252
|
+
onClick={() => selectStep(index)}
|
|
253
|
+
className={cn(
|
|
254
|
+
'h-6 shrink-0 rounded border px-2 font-mono text-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
255
|
+
index === step
|
|
256
|
+
? 'border-accent/50 bg-accent-soft font-medium text-accent'
|
|
257
|
+
: 'border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
258
|
+
)}
|
|
259
|
+
>
|
|
260
|
+
{t('retest.repeater.stepN', { n: index + 1 })}
|
|
261
|
+
</button>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
<span className={cn('shrink-0 font-mono text-[9.5px] tabular-nums text-muted-foreground', requests.length === 0 && 'ml-auto')}>
|
|
266
|
+
{t('retest.repeater.bytes', { count: rawBytes })}
|
|
267
|
+
</span>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div className="flex min-h-0 flex-1 flex-col p-3">
|
|
271
|
+
<textarea
|
|
272
|
+
data-testid="fuzzer-request"
|
|
273
|
+
aria-label={t('retest.repeater.requestAria')}
|
|
274
|
+
value={raw}
|
|
275
|
+
spellCheck={false}
|
|
276
|
+
onChange={(event) => setRaw(event.target.value)}
|
|
277
|
+
className="min-h-0 w-full flex-1 resize-none rounded-lg border border-border bg-white px-3 py-2.5 font-mono text-[11.5px] leading-relaxed text-foreground caret-accent placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
278
|
+
/>
|
|
279
|
+
{error !== null ? (
|
|
280
|
+
<p
|
|
281
|
+
role="alert"
|
|
282
|
+
data-testid="fuzzer-send-error"
|
|
283
|
+
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"
|
|
284
|
+
>
|
|
285
|
+
{error}
|
|
286
|
+
</p>
|
|
287
|
+
) : null}
|
|
288
|
+
</div>
|
|
289
|
+
</section>
|
|
290
|
+
|
|
291
|
+
{/* ------------------------------------------------------------ response */}
|
|
292
|
+
<section
|
|
293
|
+
data-testid="fuzzer-response-pane"
|
|
294
|
+
aria-label={t('fuzzer.response.aria')}
|
|
295
|
+
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-background"
|
|
296
|
+
>
|
|
297
|
+
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3">
|
|
298
|
+
<ArrowRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
299
|
+
<span className="shrink-0 text-[12px] font-medium text-foreground">{t('retest.repeater.responseTitle')}</span>
|
|
300
|
+
{result !== null ? (
|
|
301
|
+
<>
|
|
302
|
+
<span
|
|
303
|
+
data-testid="fuzzer-response-status"
|
|
304
|
+
className={`rounded border px-1.5 py-0.5 font-mono text-[10px] tabular-nums ${statusTone(result.response.status)}`}
|
|
305
|
+
>
|
|
306
|
+
{result.response.status}
|
|
307
|
+
</span>
|
|
308
|
+
<span className="font-mono text-[9.5px] tabular-nums text-muted-foreground">
|
|
309
|
+
{result.response.durationMs} ms · {result.response.body.length} B
|
|
310
|
+
</span>
|
|
311
|
+
{result.response.truncated ? (
|
|
312
|
+
<span className="text-[9.5px] text-amber-700">{t('retest.repeater.truncated')}</span>
|
|
313
|
+
) : null}
|
|
314
|
+
{responseIsRedirect ? (
|
|
315
|
+
<Button
|
|
316
|
+
variant="outline"
|
|
317
|
+
size="xs"
|
|
318
|
+
onClick={() => void send()}
|
|
319
|
+
disabled={sending}
|
|
320
|
+
className="gap-1 text-[10.5px]"
|
|
321
|
+
>
|
|
322
|
+
<CornerDownRight aria-hidden="true" className="h-3 w-3" />
|
|
323
|
+
{t('retest.repeater.followRedirect')}
|
|
324
|
+
</Button>
|
|
325
|
+
) : null}
|
|
326
|
+
{formattedJson !== null || responseIsHtml ? (
|
|
327
|
+
<div className="ml-auto flex shrink-0 items-center gap-0.5 rounded-md border border-border bg-muted p-0.5">
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
data-testid="fuzzer-view-raw"
|
|
331
|
+
onClick={() => setBodyView('raw')}
|
|
332
|
+
className={cn(
|
|
333
|
+
'rounded px-1.5 py-0.5 text-[10px] transition-colors',
|
|
334
|
+
bodyView === 'raw'
|
|
335
|
+
? 'bg-background font-medium text-foreground shadow-sm'
|
|
336
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
337
|
+
)}
|
|
338
|
+
>
|
|
339
|
+
{t('retest.repeater.viewRaw')}
|
|
340
|
+
</button>
|
|
341
|
+
{formattedJson !== null ? (
|
|
342
|
+
<button
|
|
343
|
+
type="button"
|
|
344
|
+
data-testid="fuzzer-view-json"
|
|
345
|
+
onClick={() => setBodyView('pretty')}
|
|
346
|
+
className={cn(
|
|
347
|
+
'rounded px-1.5 py-0.5 text-[10px] transition-colors',
|
|
348
|
+
bodyView === 'pretty'
|
|
349
|
+
? 'bg-background font-medium text-foreground shadow-sm'
|
|
350
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
351
|
+
)}
|
|
352
|
+
>
|
|
353
|
+
{t('retest.repeater.formatJson')}
|
|
354
|
+
</button>
|
|
355
|
+
) : null}
|
|
356
|
+
{responseIsHtml ? (
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
data-testid="fuzzer-view-html"
|
|
360
|
+
onClick={() => setBodyView('html')}
|
|
361
|
+
className={cn(
|
|
362
|
+
'rounded px-1.5 py-0.5 text-[10px] transition-colors',
|
|
363
|
+
bodyView === 'html'
|
|
364
|
+
? 'bg-background font-medium text-foreground shadow-sm'
|
|
365
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
366
|
+
)}
|
|
367
|
+
>
|
|
368
|
+
{t('retest.repeater.viewHtml')}
|
|
369
|
+
</button>
|
|
370
|
+
) : null}
|
|
371
|
+
</div>
|
|
372
|
+
) : null}
|
|
373
|
+
</>
|
|
374
|
+
) : null}
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{result === null ? (
|
|
378
|
+
<p className="flex flex-1 items-center justify-center px-3 text-[11.5px] text-muted-foreground">
|
|
379
|
+
{t('retest.repeater.emptyResponse')}
|
|
380
|
+
</p>
|
|
381
|
+
) : (
|
|
382
|
+
<div className="flex min-h-0 flex-1 flex-col p-3">
|
|
383
|
+
{responseIsHtml && bodyView === 'html' ? (
|
|
384
|
+
<iframe
|
|
385
|
+
title={t('retest.repeater.htmlPreview')}
|
|
386
|
+
sandbox=""
|
|
387
|
+
referrerPolicy="no-referrer"
|
|
388
|
+
srcDoc={htmlPreview}
|
|
389
|
+
className="min-h-0 w-full flex-1 rounded-md border border-border bg-white"
|
|
390
|
+
/>
|
|
391
|
+
) : formattedJson !== null && bodyView === 'pretty' ? (
|
|
392
|
+
<pre
|
|
393
|
+
data-testid="fuzzer-response-json"
|
|
394
|
+
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"
|
|
395
|
+
>
|
|
396
|
+
{jsonResponse}
|
|
397
|
+
</pre>
|
|
398
|
+
) : (
|
|
399
|
+
<pre
|
|
400
|
+
data-testid="fuzzer-response-body"
|
|
401
|
+
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"
|
|
402
|
+
>
|
|
403
|
+
{rawResponse.slice(0, 200_000)}
|
|
404
|
+
</pre>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
</section>
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
2
|
+
import { ArrowUp, Folder, FolderCheck, Loader2 } from 'lucide-react'
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import { Button } from '@renderer/components/ui/button'
|
|
5
|
+
import { useI18n } from '@renderer/i18n'
|
|
6
|
+
import { cn } from '@renderer/lib/utils'
|
|
7
|
+
import { getPortalContainer } from '../../../dsh/portal'
|
|
8
|
+
|
|
9
|
+
interface DirectoryListing {
|
|
10
|
+
path: string
|
|
11
|
+
parent: string | null
|
|
12
|
+
directories: Array<{ name: string; path: string }>
|
|
13
|
+
home: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DirectoryPickerDialogProps {
|
|
17
|
+
open: boolean
|
|
18
|
+
initialPath?: string
|
|
19
|
+
onOpenChange: (open: boolean) => void
|
|
20
|
+
onSelect: (path: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function listDirectories(path: string): Promise<DirectoryListing> {
|
|
24
|
+
const query = path.trim() === '' ? '' : `?path=${encodeURIComponent(path.trim())}`
|
|
25
|
+
const response = await fetch(`/cortex-api/cortex/fs/list${query}`)
|
|
26
|
+
const payload = (await response.json().catch(() => null)) as
|
|
27
|
+
| (DirectoryListing & { error?: string })
|
|
28
|
+
| null
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(payload?.error || `HTTP ${response.status}`)
|
|
31
|
+
}
|
|
32
|
+
return payload as DirectoryListing
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Server-backed folder picker: the browser cannot reveal absolute directory
|
|
37
|
+
* paths for a text export dialog, so the Host lists folders and this dialog
|
|
38
|
+
* returns the chosen absolute path.
|
|
39
|
+
*/
|
|
40
|
+
export function DirectoryPickerDialog({
|
|
41
|
+
open,
|
|
42
|
+
initialPath = '',
|
|
43
|
+
onOpenChange,
|
|
44
|
+
onSelect
|
|
45
|
+
}: DirectoryPickerDialogProps) {
|
|
46
|
+
const { t } = useI18n()
|
|
47
|
+
const [current, setCurrent] = useState('')
|
|
48
|
+
const [parent, setParent] = useState<string | null>(null)
|
|
49
|
+
const [directories, setDirectories] = useState<Array<{ name: string; path: string }>>([])
|
|
50
|
+
const [manual, setManual] = useState('')
|
|
51
|
+
const [loading, setLoading] = useState(false)
|
|
52
|
+
const [error, setError] = useState<string | null>(null)
|
|
53
|
+
|
|
54
|
+
const load = useCallback(
|
|
55
|
+
async (target: string) => {
|
|
56
|
+
setLoading(true)
|
|
57
|
+
setError(null)
|
|
58
|
+
try {
|
|
59
|
+
const listing = await listDirectories(target)
|
|
60
|
+
setCurrent(listing.path)
|
|
61
|
+
setParent(listing.parent)
|
|
62
|
+
setDirectories(listing.directories)
|
|
63
|
+
setManual(listing.path)
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false)
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) return
|
|
75
|
+
void load(initialPath)
|
|
76
|
+
// Only reload when the dialog opens; user navigation is handled locally.
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [open])
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
|
|
82
|
+
<DialogPrimitive.Portal container={getPortalContainer()}>
|
|
83
|
+
<DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[80] bg-[hsl(224_18%_10%/0.3)]" />
|
|
84
|
+
<DialogPrimitive.Content
|
|
85
|
+
data-testid="directory-picker-dialog"
|
|
86
|
+
className="fixed left-1/2 top-1/2 z-[80] flex w-[min(620px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
87
|
+
>
|
|
88
|
+
<DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
|
|
89
|
+
{t('vuln.dirPicker.title')}
|
|
90
|
+
</DialogPrimitive.Title>
|
|
91
|
+
|
|
92
|
+
<div className="mt-3 flex items-center gap-1.5">
|
|
93
|
+
<input
|
|
94
|
+
data-testid="directory-picker-path"
|
|
95
|
+
value={manual}
|
|
96
|
+
spellCheck={false}
|
|
97
|
+
onChange={(event) => setManual(event.target.value)}
|
|
98
|
+
onKeyDown={(event) => {
|
|
99
|
+
if (event.key === 'Enter') void load(manual)
|
|
100
|
+
}}
|
|
101
|
+
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
+
/>
|
|
103
|
+
<Button
|
|
104
|
+
variant="outline"
|
|
105
|
+
size="xs"
|
|
106
|
+
onClick={() => void load(manual)}
|
|
107
|
+
disabled={loading}
|
|
108
|
+
className="h-8 gap-1 px-2.5"
|
|
109
|
+
>
|
|
110
|
+
{t('vuln.dirPicker.go')}
|
|
111
|
+
</Button>
|
|
112
|
+
<Button
|
|
113
|
+
variant="outline"
|
|
114
|
+
size="xs"
|
|
115
|
+
data-testid="directory-picker-up"
|
|
116
|
+
onClick={() => {
|
|
117
|
+
if (parent !== null) void load(parent)
|
|
118
|
+
}}
|
|
119
|
+
disabled={loading || parent === null}
|
|
120
|
+
className="h-8 gap-1 px-2.5"
|
|
121
|
+
>
|
|
122
|
+
<ArrowUp aria-hidden="true" className="h-3 w-3" />
|
|
123
|
+
{t('vuln.dirPicker.up')}
|
|
124
|
+
</Button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="mt-3 max-h-[320px] min-h-[180px] overflow-y-auto rounded-lg border border-border">
|
|
128
|
+
{loading ? (
|
|
129
|
+
<p className="flex h-[180px] items-center justify-center gap-2 text-[11.5px] text-muted-foreground">
|
|
130
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
131
|
+
{t('vuln.dirPicker.loading')}
|
|
132
|
+
</p>
|
|
133
|
+
) : error !== null ? (
|
|
134
|
+
<p className="p-3 text-[11.5px] text-red-700">
|
|
135
|
+
{t('vuln.dirPicker.error', { error })}
|
|
136
|
+
</p>
|
|
137
|
+
) : directories.length === 0 ? (
|
|
138
|
+
<p className="flex h-[180px] items-center justify-center text-[11.5px] text-muted-foreground">
|
|
139
|
+
{t('vuln.dirPicker.empty')}
|
|
140
|
+
</p>
|
|
141
|
+
) : (
|
|
142
|
+
<ul className="divide-y divide-border">
|
|
143
|
+
{directories.map((entry) => (
|
|
144
|
+
<li key={entry.path}>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
data-testid="directory-picker-entry"
|
|
148
|
+
onClick={() => void load(entry.path)}
|
|
149
|
+
className={cn(
|
|
150
|
+
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
|
|
151
|
+
'hover:bg-sidebar focus-visible:bg-accent-soft focus-visible:outline-none'
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
<Folder aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
|
155
|
+
<span className="truncate text-[12px] text-foreground">{entry.name}</span>
|
|
156
|
+
</button>
|
|
157
|
+
</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="mt-4 flex items-center justify-between gap-2">
|
|
164
|
+
<span className="min-w-0 truncate font-mono text-[10.5px] text-muted-foreground" title={current}>
|
|
165
|
+
{current}
|
|
166
|
+
</span>
|
|
167
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
168
|
+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
|
169
|
+
{t('common.cancel')}
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
size="sm"
|
|
173
|
+
data-testid="directory-picker-select"
|
|
174
|
+
disabled={loading || error !== null || current === ''}
|
|
175
|
+
onClick={() => onSelect(current)}
|
|
176
|
+
className="gap-1.5"
|
|
177
|
+
>
|
|
178
|
+
<FolderCheck aria-hidden="true" className="h-3.5 w-3.5" />
|
|
179
|
+
{t('vuln.dirPicker.select')}
|
|
180
|
+
</Button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</DialogPrimitive.Content>
|
|
184
|
+
</DialogPrimitive.Portal>
|
|
185
|
+
</DialogPrimitive.Root>
|
|
186
|
+
)
|
|
187
|
+
}
|