@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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the single-Repeater history / favorites Host routes.
|
|
3
|
+
*
|
|
4
|
+
* The routes live under the same `/cortex-api/cortex` surface but are not part
|
|
5
|
+
* of `CortexApi`; the dialogs use these plain same-origin fetches.
|
|
6
|
+
*/
|
|
7
|
+
import type { RepeaterSendResult } from '@shared/repeater'
|
|
8
|
+
|
|
9
|
+
const API_BASE = '/cortex-api/cortex'
|
|
10
|
+
|
|
11
|
+
export type RepeaterStoredRequest = RepeaterSendResult['request']
|
|
12
|
+
export type RepeaterStoredResponse = RepeaterSendResult['response']
|
|
13
|
+
|
|
14
|
+
export interface RepeaterHistoryEntry {
|
|
15
|
+
id: string
|
|
16
|
+
createdAt: string
|
|
17
|
+
vulnId: string
|
|
18
|
+
raw: string
|
|
19
|
+
request: RepeaterStoredRequest
|
|
20
|
+
response: RepeaterStoredResponse | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RepeaterCollectionEntry {
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
createdAt: string
|
|
27
|
+
vulnId: string
|
|
28
|
+
raw: string
|
|
29
|
+
request: RepeaterStoredRequest
|
|
30
|
+
response: RepeaterStoredResponse | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SaveRepeaterCollectionInput {
|
|
34
|
+
name: string
|
|
35
|
+
vulnId: string
|
|
36
|
+
raw: string
|
|
37
|
+
request: RepeaterStoredRequest
|
|
38
|
+
response?: RepeaterStoredResponse | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ApiErrorPayload {
|
|
42
|
+
error?: unknown
|
|
43
|
+
message?: unknown
|
|
44
|
+
code?: unknown
|
|
45
|
+
details?: unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Stable client error: `code` mirrors the Host error envelope when present. */
|
|
49
|
+
export class RepeaterStoreApiError extends Error {
|
|
50
|
+
readonly code: string
|
|
51
|
+
readonly status: number
|
|
52
|
+
readonly details: unknown
|
|
53
|
+
|
|
54
|
+
constructor(code: string, message: string, status: number, details?: unknown) {
|
|
55
|
+
super(message)
|
|
56
|
+
this.name = 'RepeaterStoreApiError'
|
|
57
|
+
this.code = code
|
|
58
|
+
this.status = status
|
|
59
|
+
this.details = details
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function errorMessage(error: unknown): string {
|
|
64
|
+
if (error instanceof RepeaterStoreApiError) return error.message
|
|
65
|
+
if (error instanceof Error && error.message !== '') return error.message
|
|
66
|
+
return typeof error === 'string' && error !== '' ? error : 'Unknown error'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { errorMessage as repeaterStoreErrorMessage }
|
|
70
|
+
|
|
71
|
+
interface RequestOptions {
|
|
72
|
+
method?: 'GET' | 'POST' | 'DELETE'
|
|
73
|
+
body?: unknown
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
77
|
+
const init: RequestInit = { method: options.method ?? 'GET' }
|
|
78
|
+
if (options.body !== undefined) {
|
|
79
|
+
init.headers = { 'Content-Type': 'application/json' }
|
|
80
|
+
init.body = JSON.stringify(options.body)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let response: Response
|
|
84
|
+
try {
|
|
85
|
+
response = await fetch(`${API_BASE}${path}`, init)
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
const detail = cause instanceof Error ? cause.message : String(cause)
|
|
88
|
+
throw new RepeaterStoreApiError('NETWORK_ERROR', detail, 0)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const text = await response.text().catch(() => '')
|
|
92
|
+
let payload: unknown = null
|
|
93
|
+
if (text !== '') {
|
|
94
|
+
try {
|
|
95
|
+
payload = JSON.parse(text) as unknown
|
|
96
|
+
} catch {
|
|
97
|
+
payload = null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
const envelope = (payload ?? {}) as ApiErrorPayload
|
|
103
|
+
const message =
|
|
104
|
+
typeof envelope.error === 'string' && envelope.error !== ''
|
|
105
|
+
? envelope.error
|
|
106
|
+
: typeof envelope.message === 'string' && envelope.message !== ''
|
|
107
|
+
? envelope.message
|
|
108
|
+
: `HTTP ${response.status}`
|
|
109
|
+
const code =
|
|
110
|
+
typeof envelope.code === 'string' && envelope.code !== ''
|
|
111
|
+
? envelope.code
|
|
112
|
+
: `HTTP_${response.status}`
|
|
113
|
+
throw new RepeaterStoreApiError(code, message, response.status, envelope.details)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return payload as T
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const encodeId = (value: string): string => encodeURIComponent(value)
|
|
120
|
+
|
|
121
|
+
function unwrapItems<T>(payload: unknown): T[] {
|
|
122
|
+
if (Array.isArray(payload)) return payload as T[]
|
|
123
|
+
if (payload !== null && typeof payload === 'object') {
|
|
124
|
+
const items = (payload as { items?: unknown }).items
|
|
125
|
+
if (Array.isArray(items)) return items as T[]
|
|
126
|
+
}
|
|
127
|
+
return []
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function listRepeaterHistory(limit = 20): Promise<RepeaterHistoryEntry[]> {
|
|
131
|
+
const safeLimit = Math.max(1, Math.min(50, Math.trunc(limit)))
|
|
132
|
+
return requestJson<unknown>(`/repeater/history?limit=${safeLimit}`).then((payload) =>
|
|
133
|
+
unwrapItems<RepeaterHistoryEntry>(payload)
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function clearRepeaterHistory(): Promise<boolean> {
|
|
138
|
+
return requestJson<{ cleared?: boolean }>('/repeater/history', { method: 'DELETE' }).then(
|
|
139
|
+
(payload) => payload.cleared === true
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function listRepeaterCollections(): Promise<RepeaterCollectionEntry[]> {
|
|
144
|
+
return requestJson<unknown>('/repeater/collections').then((payload) =>
|
|
145
|
+
unwrapItems<RepeaterCollectionEntry>(payload)
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function saveRepeaterCollection(
|
|
150
|
+
input: SaveRepeaterCollectionInput
|
|
151
|
+
): Promise<RepeaterCollectionEntry> {
|
|
152
|
+
return requestJson<{ item: RepeaterCollectionEntry }>('/repeater/collections', {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
body: input
|
|
155
|
+
}).then((payload) => payload.item)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function deleteRepeaterCollection(id: string): Promise<boolean> {
|
|
159
|
+
return requestJson<{ deleted?: boolean }>(
|
|
160
|
+
`/repeater/collections/${encodeId(id)}`,
|
|
161
|
+
{ method: 'DELETE' }
|
|
162
|
+
).then((payload) => payload.deleted === true)
|
|
163
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response formatting helpers for the retest repeater (Yakit/Burp-style JSON
|
|
3
|
+
* view) and the Fuzzer result detail. Kept away from the TSX components so
|
|
4
|
+
* they are unit-testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Pretty-print JSON bodies; null when the body is not JSON. */
|
|
8
|
+
export function prettyJsonBody(body: string, contentType: string): string | null {
|
|
9
|
+
const trimmed = body.trim()
|
|
10
|
+
if (trimmed.length === 0) return null
|
|
11
|
+
if (!/json/i.test(contentType) && !/^[{[]/.test(trimmed)) return null
|
|
12
|
+
if (trimmed.length > 2_000_000) return null
|
|
13
|
+
try {
|
|
14
|
+
return JSON.stringify(JSON.parse(body), null, 2)
|
|
15
|
+
} catch {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Case-insensitive response/request header lookup. */
|
|
21
|
+
export function headerValue(headers: Record<string, string>, name: string): string {
|
|
22
|
+
const lower = name.toLowerCase()
|
|
23
|
+
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lower)
|
|
24
|
+
return entry?.[1] ?? ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when a response declares (or visibly contains) HTML. */
|
|
28
|
+
export function isHtmlResponse(body: string, headers: Record<string, string>): boolean {
|
|
29
|
+
const contentType = headerValue(headers, 'content-type')
|
|
30
|
+
if (/text\/html|application\/xhtml\+xml/i.test(contentType)) return true
|
|
31
|
+
return /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(body.slice(0, 300))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rendered HTML needs a base URL so relative CSS/images resolve against the target. */
|
|
35
|
+
export function buildHtmlPreview(body: string, targetUrl: string): string {
|
|
36
|
+
let base = ''
|
|
37
|
+
try {
|
|
38
|
+
base = `${new URL(targetUrl).origin}/`
|
|
39
|
+
} catch {
|
|
40
|
+
base = ''
|
|
41
|
+
}
|
|
42
|
+
if (base === '') return body
|
|
43
|
+
const tag = `<base href="${base.replace(/"/g, '"')}">`
|
|
44
|
+
if (/<head(?:\s[^>]*)?>/i.test(body)) {
|
|
45
|
+
return body.replace(/<head(?:\s[^>]*)?>/i, (match) => `${match}${tag}`)
|
|
46
|
+
}
|
|
47
|
+
return `${tag}${body}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tailwind tone for a response status chip. */
|
|
51
|
+
export function statusTone(status: number): string {
|
|
52
|
+
if (status >= 500) return 'border-red-300 bg-red-50 text-red-800'
|
|
53
|
+
if (status >= 400) return 'border-amber-300 bg-amber-50 text-amber-900'
|
|
54
|
+
if (status >= 300) return 'border-sky-300 bg-sky-50 text-sky-900'
|
|
55
|
+
return 'border-emerald-300 bg-emerald-50 text-emerald-900'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when a response status redirects and carries a Location header. */
|
|
59
|
+
export function isRedirectResponse(
|
|
60
|
+
status: number,
|
|
61
|
+
headers: Record<string, string>
|
|
62
|
+
): boolean {
|
|
63
|
+
return status >= 300 && status < 400 && headerValue(headers, 'location') !== ''
|
|
64
|
+
}
|
|
@@ -263,7 +263,7 @@ export function createHttpApi(): CortexApi {
|
|
|
263
263
|
*/
|
|
264
264
|
exportVulnerabilities: async (
|
|
265
265
|
ids: string[],
|
|
266
|
-
|
|
266
|
+
_options?: { outputDir?: string; format?: string }
|
|
267
267
|
): Promise<ExportReportsResult> => {
|
|
268
268
|
const response = await request<WebExportResponse>(WEB_API_PATHS.vulnerabilitiesExport, {
|
|
269
269
|
method: 'POST',
|
|
@@ -304,7 +304,7 @@ export function DashboardPage({
|
|
|
304
304
|
[items]
|
|
305
305
|
)
|
|
306
306
|
|
|
307
|
-
const handleExportSelected = async (outputDir?: string) => {
|
|
307
|
+
const handleExportSelected = async (outputDir?: string, format?: 'md' | 'docx' | 'pdf') => {
|
|
308
308
|
const ids = [...selectedIds]
|
|
309
309
|
if (ids.length === 0) return
|
|
310
310
|
const api = getApi()
|
|
@@ -315,7 +315,7 @@ export function DashboardPage({
|
|
|
315
315
|
}
|
|
316
316
|
setExporting(true)
|
|
317
317
|
try {
|
|
318
|
-
const result = await api.exportVulnerabilities(ids, outputDir)
|
|
318
|
+
const result = await api.exportVulnerabilities(ids, { outputDir, format })
|
|
319
319
|
const savedPath = result.savedPaths?.[0]
|
|
320
320
|
if (!result.cancelled) {
|
|
321
321
|
const message =
|
|
@@ -536,7 +536,7 @@ export function DashboardPage({
|
|
|
536
536
|
count={selectedCount}
|
|
537
537
|
pending={exporting}
|
|
538
538
|
onOpenChange={setExportOpen}
|
|
539
|
-
onConfirm={(outputDir) => void handleExportSelected(outputDir)}
|
|
539
|
+
onConfirm={(outputDir, format) => void handleExportSelected(outputDir, format)}
|
|
540
540
|
/>
|
|
541
541
|
</>
|
|
542
542
|
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Zap } from 'lucide-react'
|
|
3
|
+
import type { Severity, Vulnerability, VulnerabilityListItem } from '@shared/types'
|
|
4
|
+
import { YakitSendPanel } from '@renderer/components/retest/YakitSendPanel'
|
|
5
|
+
import { SeverityBadge, StatusBadge } from '@renderer/components/common/Badges'
|
|
6
|
+
import {
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue
|
|
12
|
+
} from '@renderer/components/ui/select'
|
|
13
|
+
import { Skeleton } from '@renderer/components/ui/skeleton'
|
|
14
|
+
import { useI18n } from '@renderer/i18n'
|
|
15
|
+
import { getApi } from '@renderer/lib/api'
|
|
16
|
+
|
|
17
|
+
interface FuzzerPageProps {
|
|
18
|
+
dataVersion: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TARGET_STORAGE_KEY = 'cortex.fuzzer.target'
|
|
22
|
+
|
|
23
|
+
function storedTargetId(): string {
|
|
24
|
+
try {
|
|
25
|
+
return window.localStorage.getItem(TARGET_STORAGE_KEY) ?? ''
|
|
26
|
+
} catch {
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rememberTarget(id: string): void {
|
|
32
|
+
try {
|
|
33
|
+
window.localStorage.setItem(TARGET_STORAGE_KEY, id)
|
|
34
|
+
} catch {
|
|
35
|
+
/* localStorage may be unavailable in a hardened WebView */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Dedicated Web Fuzzer page: a Yakit-style manual HTTP sender with the request
|
|
41
|
+
* editor on the left, the response viewer on the right and both panes filling
|
|
42
|
+
* the available height. There is deliberately no ported vulnerability list and
|
|
43
|
+
* no batch fuzzing: a compact target selector in the header is the only link to
|
|
44
|
+
* a finding, because the Host derives the outbound origin allowlist from it.
|
|
45
|
+
*/
|
|
46
|
+
export function FuzzerPage({ dataVersion }: FuzzerPageProps) {
|
|
47
|
+
const { t } = useI18n()
|
|
48
|
+
const [items, setItems] = useState<VulnerabilityListItem[] | null>(null)
|
|
49
|
+
const [listLoading, setListLoading] = useState(true)
|
|
50
|
+
const [listError, setListError] = useState<string | null>(null)
|
|
51
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
52
|
+
const [detail, setDetail] = useState<Vulnerability | null>(null)
|
|
53
|
+
const [detailLoading, setDetailLoading] = useState(false)
|
|
54
|
+
const [detailError, setDetailError] = useState<string | null>(null)
|
|
55
|
+
const detailCache = useRef(new Map<string, Vulnerability>())
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const api = getApi()
|
|
59
|
+
if (!api) {
|
|
60
|
+
setListError(t('common.apiUnavailable'))
|
|
61
|
+
setListLoading(false)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
let alive = true
|
|
65
|
+
setListLoading(true)
|
|
66
|
+
api
|
|
67
|
+
.listVulnerabilities({ realm: 'web', page: 1, pageSize: 200 })
|
|
68
|
+
.then(
|
|
69
|
+
(result) => {
|
|
70
|
+
if (!alive) return
|
|
71
|
+
setItems(result.items)
|
|
72
|
+
setListError(null)
|
|
73
|
+
setListLoading(false)
|
|
74
|
+
const remembered = storedTargetId()
|
|
75
|
+
setSelectedId((current) => {
|
|
76
|
+
const preferred = current ?? remembered
|
|
77
|
+
if (preferred !== '' && result.items.some((item) => item.id === preferred)) {
|
|
78
|
+
return preferred
|
|
79
|
+
}
|
|
80
|
+
return result.items[0]?.id ?? null
|
|
81
|
+
})
|
|
82
|
+
},
|
|
83
|
+
(cause: unknown) => {
|
|
84
|
+
if (!alive) return
|
|
85
|
+
setListError(cause instanceof Error ? cause.message : String(cause))
|
|
86
|
+
setListLoading(false)
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
return () => {
|
|
90
|
+
alive = false
|
|
91
|
+
}
|
|
92
|
+
}, [dataVersion, t])
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (selectedId === null) {
|
|
96
|
+
setDetail(null)
|
|
97
|
+
setDetailLoading(false)
|
|
98
|
+
setDetailError(null)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
rememberTarget(selectedId)
|
|
102
|
+
const cached = detailCache.current.get(selectedId)
|
|
103
|
+
if (cached !== undefined) {
|
|
104
|
+
setDetail(cached)
|
|
105
|
+
setDetailLoading(false)
|
|
106
|
+
setDetailError(null)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const api = getApi()
|
|
110
|
+
if (!api) {
|
|
111
|
+
setDetailError(t('common.apiUnavailable'))
|
|
112
|
+
setDetailLoading(false)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
let alive = true
|
|
116
|
+
setDetail(null)
|
|
117
|
+
setDetailLoading(true)
|
|
118
|
+
setDetailError(null)
|
|
119
|
+
api.getVulnerability(selectedId).then(
|
|
120
|
+
(next) => {
|
|
121
|
+
if (!alive) return
|
|
122
|
+
if (next === null) {
|
|
123
|
+
setDetailError(t('fuzzer.target.detailFailed'))
|
|
124
|
+
} else {
|
|
125
|
+
detailCache.current.set(next.id, next)
|
|
126
|
+
setDetail(next)
|
|
127
|
+
}
|
|
128
|
+
setDetailLoading(false)
|
|
129
|
+
},
|
|
130
|
+
(cause: unknown) => {
|
|
131
|
+
if (!alive) return
|
|
132
|
+
setDetailError(cause instanceof Error ? cause.message : String(cause))
|
|
133
|
+
setDetailLoading(false)
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
return () => {
|
|
137
|
+
alive = false
|
|
138
|
+
}
|
|
139
|
+
}, [selectedId, dataVersion, t])
|
|
140
|
+
|
|
141
|
+
const selectedItem = items?.find((item) => item.id === selectedId) ?? null
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
data-testid="fuzzer-page"
|
|
146
|
+
className="mx-auto flex h-full min-h-0 w-full max-w-[1680px] flex-col px-6 py-4"
|
|
147
|
+
>
|
|
148
|
+
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3">
|
|
149
|
+
<h1 className="text-[19px] font-semibold tracking-tight text-foreground">
|
|
150
|
+
{t('nav.fuzzer')}
|
|
151
|
+
</h1>
|
|
152
|
+
|
|
153
|
+
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
|
154
|
+
<span className="text-[10.5px] text-muted-foreground">{t('fuzzer.target.title')}</span>
|
|
155
|
+
{listLoading && items === null ? (
|
|
156
|
+
<Skeleton className="h-8 w-[300px]" />
|
|
157
|
+
) : (
|
|
158
|
+
<Select
|
|
159
|
+
value={selectedId ?? undefined}
|
|
160
|
+
onValueChange={(value) => setSelectedId(value)}
|
|
161
|
+
disabled={items === null || items.length === 0}
|
|
162
|
+
>
|
|
163
|
+
<SelectTrigger
|
|
164
|
+
data-testid="fuzzer-target-select"
|
|
165
|
+
aria-label={t('fuzzer.target.title')}
|
|
166
|
+
className="h-8 w-[300px] text-[11.5px]"
|
|
167
|
+
>
|
|
168
|
+
<SelectValue placeholder={t('fuzzer.target.select')} />
|
|
169
|
+
</SelectTrigger>
|
|
170
|
+
<SelectContent className="max-h-[320px]">
|
|
171
|
+
{(items ?? []).map((item) => (
|
|
172
|
+
<SelectItem key={item.id} value={item.id} className="text-[11.5px]">
|
|
173
|
+
{item.vuln_name}
|
|
174
|
+
</SelectItem>
|
|
175
|
+
))}
|
|
176
|
+
</SelectContent>
|
|
177
|
+
</Select>
|
|
178
|
+
)}
|
|
179
|
+
{selectedItem !== null ? (
|
|
180
|
+
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
|
181
|
+
<SeverityBadge severity={selectedItem.severity as Severity} />
|
|
182
|
+
<span className="max-w-[180px] truncate font-mono">{selectedItem.host}</span>
|
|
183
|
+
<StatusBadge status={selectedItem.status} />
|
|
184
|
+
</span>
|
|
185
|
+
) : null}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{listError !== null ? (
|
|
190
|
+
<div role="alert" className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[11.5px] text-red-700">
|
|
191
|
+
{t('fuzzer.target.loadFailed', { error: listError })}
|
|
192
|
+
</div>
|
|
193
|
+
) : null}
|
|
194
|
+
|
|
195
|
+
<div className="mt-3 flex min-h-0 flex-1 flex-col">
|
|
196
|
+
{detailError !== null ? (
|
|
197
|
+
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[11.5px] text-red-700">
|
|
198
|
+
{detailError}
|
|
199
|
+
</div>
|
|
200
|
+
) : detail === null && listError === null && !listLoading && (items?.length ?? 0) === 0 ? (
|
|
201
|
+
<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">
|
|
202
|
+
{t('fuzzer.target.empty')}
|
|
203
|
+
</div>
|
|
204
|
+
) : detail === null ? (
|
|
205
|
+
<YakitSendPanel vuln={null} loading={detailLoading || listLoading || (items?.length ?? 0) > 0} />
|
|
206
|
+
) : (
|
|
207
|
+
<YakitSendPanel key={detail.id} vuln={detail} loading={detailLoading} />
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|