@lievclouds/dsh-cortex 0.4.1 → 0.4.3
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/client.js +419 -419
- package/package.json +1 -1
- package/src/cortex-ui/dsh/api.ts +13 -1
- package/src/cortex-ui/renderer/components/asset/AssetTable.tsx +21 -27
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +3 -2
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +120 -0
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +21 -4
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +16 -0
- package/src/cortex-ui/renderer/lib/retestRequest.ts +17 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +4 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +20 -4
- package/src/cortex-ui/shared/ipc.ts +6 -2
- package/src/cortex-ui/shared/types.ts +3 -1
- package/src/cortex-ui/shared/web.ts +2 -0
- package/src/host/cortex/vulns.js +59 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lievclouds/dsh-cortex",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Cortex workbench for DeepSeek Harness: the original Cortex UI (vulnerabilities, assets, internal operations, Memory Nebula, snapshots, retest) as a native DSH panel, plus 12 native agent tools so DSH conversations use the local Cortex SQLite without MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/src/cortex-ui/dsh/api.ts
CHANGED
|
@@ -112,7 +112,19 @@ export function createCortexApi(): CortexApi {
|
|
|
112
112
|
patch(`/vulns/${encodeURIComponent(input.id)}/status`, { status: input.status }),
|
|
113
113
|
deleteVulnerability: (id) => del(`/vulns/${encodeURIComponent(id)}`),
|
|
114
114
|
deleteVulnerabilities: (ids) => post('/vulns/delete-many', { ids }),
|
|
115
|
-
exportVulnerabilities: (ids) =>
|
|
115
|
+
exportVulnerabilities: async (ids, outputDir) => {
|
|
116
|
+
const response = await post('/vulns/export', {
|
|
117
|
+
ids,
|
|
118
|
+
...(outputDir && outputDir.trim() !== '' ? { outputDir: outputDir.trim() } : {}),
|
|
119
|
+
})
|
|
120
|
+
const files = Array.isArray(response?.files) ? response.files : []
|
|
121
|
+
const savedPaths = Array.isArray(response?.savedPaths) ? response.savedPaths : []
|
|
122
|
+
return {
|
|
123
|
+
cancelled: response?.cancelled === true || files.length === 0,
|
|
124
|
+
files: files.map((file) => (file && typeof file.filename === 'string' ? file.filename : String(file))),
|
|
125
|
+
savedPaths: savedPaths.filter((path) => typeof path === 'string'),
|
|
126
|
+
}
|
|
127
|
+
},
|
|
116
128
|
|
|
117
129
|
// ---------------------------------------------------------------- assets
|
|
118
130
|
listAssets: (filters) => get('/assets', filters as Record<string, unknown>),
|
|
@@ -23,12 +23,15 @@ interface AssetTableProps {
|
|
|
23
23
|
const thBase =
|
|
24
24
|
'sticky top-0 z-10 border-b border-border bg-background px-3 py-2 text-left text-[11px] font-medium text-foreground/70'
|
|
25
25
|
|
|
26
|
-
/** Table cells share
|
|
26
|
+
/** Table cells share a fixed row height and the hairline bottom border. */
|
|
27
27
|
function cellClass(last: boolean, extra?: string): string {
|
|
28
|
-
return cn('border-b border-border px-3 py-2
|
|
28
|
+
return cn('h-14 border-b border-border px-3 py-2 align-middle', last && 'border-b-0', extra)
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Fixed single-line fingerprint summary: list rows keep one constant height, so
|
|
33
|
+
* a long stack is clipped with an ellipsis instead of growing the row.
|
|
34
|
+
*/
|
|
32
35
|
function TechStackTags({ techStack }: { techStack: string }) {
|
|
33
36
|
const { t } = useI18n()
|
|
34
37
|
const tags = techStackTags(techStack)
|
|
@@ -36,23 +39,14 @@ function TechStackTags({ techStack }: { techStack: string }) {
|
|
|
36
39
|
return <span className="text-[11px] text-muted-foreground">{t('asset.value.notRecorded')}</span>
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
const
|
|
40
|
-
const hiddenCount = tags.length - visibleTags.length
|
|
41
|
-
|
|
42
|
+
const label = tags.join(' · ')
|
|
42
43
|
return (
|
|
43
|
-
<span
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
>
|
|
50
|
-
{tag}
|
|
51
|
-
</span>
|
|
52
|
-
))}
|
|
53
|
-
{hiddenCount > 0 ? (
|
|
54
|
-
<span className="font-mono text-[11px] tabular-nums text-foreground/60">+{hiddenCount}</span>
|
|
55
|
-
) : null}
|
|
44
|
+
<span
|
|
45
|
+
className="block truncate font-mono text-[11px] leading-5 text-foreground/75"
|
|
46
|
+
title={label}
|
|
47
|
+
data-testid="asset-tech-stack"
|
|
48
|
+
>
|
|
49
|
+
{label}
|
|
56
50
|
</span>
|
|
57
51
|
)
|
|
58
52
|
}
|
|
@@ -83,28 +77,28 @@ function TableSkeleton() {
|
|
|
83
77
|
<tbody>
|
|
84
78
|
{[0, 1, 2, 3, 4, 5].map((index) => (
|
|
85
79
|
<tr key={index}>
|
|
86
|
-
<td className="border-b border-border px-3 py-
|
|
80
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
87
81
|
<Skeleton className="h-3.5 w-[182px]" />
|
|
88
82
|
</td>
|
|
89
|
-
<td className="border-b border-border px-3 py-
|
|
83
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
90
84
|
<Skeleton className="h-3 w-[92px]" />
|
|
91
85
|
</td>
|
|
92
|
-
<td className="border-b border-border px-3 py-
|
|
86
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
93
87
|
<Skeleton className="h-3 w-[68%]" />
|
|
94
88
|
</td>
|
|
95
|
-
<td className="border-b border-border px-3 py-
|
|
89
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
96
90
|
<Skeleton className="mx-auto h-[22px] w-[56px] rounded-full" />
|
|
97
91
|
</td>
|
|
98
|
-
<td className="border-b border-border px-3 py-
|
|
92
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
99
93
|
<Skeleton className="mx-auto h-3 w-5" />
|
|
100
94
|
</td>
|
|
101
|
-
<td className="border-b border-border px-3 py-
|
|
95
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
102
96
|
<Skeleton className="mx-auto h-3 w-5" />
|
|
103
97
|
</td>
|
|
104
|
-
<td className="border-b border-border px-3 py-
|
|
98
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
105
99
|
<Skeleton className="mx-auto h-3 w-5" />
|
|
106
100
|
</td>
|
|
107
|
-
<td className="border-b border-border px-3 py-
|
|
101
|
+
<td className="h-14 border-b border-border px-3 py-0">
|
|
108
102
|
<Skeleton className="h-3 w-[62px]" />
|
|
109
103
|
</td>
|
|
110
104
|
<td className="border-b border-border py-3 pr-4" />
|
|
@@ -8,7 +8,8 @@ import { useI18n } from '@renderer/i18n'
|
|
|
8
8
|
import { getApi } from '@renderer/lib/api'
|
|
9
9
|
import {
|
|
10
10
|
defaultRequestIndex,
|
|
11
|
-
extractRepeaterRequests
|
|
11
|
+
extractRepeaterRequests,
|
|
12
|
+
resolveRepeaterUrl
|
|
12
13
|
} from '@renderer/lib/retestRequest'
|
|
13
14
|
|
|
14
15
|
interface RepeaterPanelProps {
|
|
@@ -159,7 +160,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
159
160
|
const response = await api.sendRepeaterRequest({
|
|
160
161
|
vulnId: vuln.id,
|
|
161
162
|
method: parsed.method,
|
|
162
|
-
url: parsed.url,
|
|
163
|
+
url: resolveRepeaterUrl(parsed.url, vuln.target_url),
|
|
163
164
|
headers: parsed.headers,
|
|
164
165
|
...(parsed.body === '' ? {} : { body: parsed.body }),
|
|
165
166
|
followRedirects
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
2
|
+
import { Download, FolderOpen, Loader2 } from 'lucide-react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Button } from '@renderer/components/ui/button'
|
|
5
|
+
import { useI18n } from '@renderer/i18n'
|
|
6
|
+
import { getPortalContainer } from '../../../dsh/portal'
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'cortex.export.dir'
|
|
9
|
+
|
|
10
|
+
function isAbsoluteLike(value: string): boolean {
|
|
11
|
+
const trimmed = value.trim()
|
|
12
|
+
if (trimmed.startsWith('~')) return true
|
|
13
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed)) return true
|
|
14
|
+
if (trimmed.startsWith('\\\\') || trimmed.startsWith('//')) return true
|
|
15
|
+
return trimmed.startsWith('/')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ExportReportDialogProps {
|
|
19
|
+
open: boolean
|
|
20
|
+
/** Number of reports being exported, for the title/confirm label. */
|
|
21
|
+
count: number
|
|
22
|
+
pending: boolean
|
|
23
|
+
onOpenChange: (open: boolean) => void
|
|
24
|
+
onConfirm: (outputDir: string | undefined) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Export-path picker. The DSH WebView cannot open a native directory picker, so
|
|
29
|
+
* the user types an absolute directory (or leaves it empty for the default
|
|
30
|
+
* `~/.cortex/exports/<timestamp>/`); the Host writes the Markdown there.
|
|
31
|
+
*/
|
|
32
|
+
export function ExportReportDialog({
|
|
33
|
+
open,
|
|
34
|
+
count,
|
|
35
|
+
pending,
|
|
36
|
+
onOpenChange,
|
|
37
|
+
onConfirm
|
|
38
|
+
}: ExportReportDialogProps) {
|
|
39
|
+
const { t } = useI18n()
|
|
40
|
+
const [directory, setDirectory] = useState('')
|
|
41
|
+
const [invalid, setInvalid] = useState(false)
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return
|
|
45
|
+
const stored = window.localStorage.getItem(STORAGE_KEY) ?? ''
|
|
46
|
+
setDirectory(stored)
|
|
47
|
+
setInvalid(false)
|
|
48
|
+
}, [open])
|
|
49
|
+
|
|
50
|
+
const confirm = (): void => {
|
|
51
|
+
const trimmed = directory.trim()
|
|
52
|
+
if (trimmed !== '' && !isAbsoluteLike(trimmed)) {
|
|
53
|
+
setInvalid(true)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (trimmed !== '') window.localStorage.setItem(STORAGE_KEY, trimmed)
|
|
57
|
+
else window.localStorage.removeItem(STORAGE_KEY)
|
|
58
|
+
onConfirm(trimmed === '' ? undefined : trimmed)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
|
|
63
|
+
<DialogPrimitive.Portal container={getPortalContainer()}>
|
|
64
|
+
<DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
|
|
65
|
+
<DialogPrimitive.Content
|
|
66
|
+
data-testid="export-report-dialog"
|
|
67
|
+
className="fixed left-1/2 top-1/2 z-[70] w-[min(480px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 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"
|
|
68
|
+
>
|
|
69
|
+
<DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
|
|
70
|
+
{t('vuln.export.title')}
|
|
71
|
+
</DialogPrimitive.Title>
|
|
72
|
+
<DialogPrimitive.Description className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
|
73
|
+
{t('vuln.export.description', { count })}
|
|
74
|
+
</DialogPrimitive.Description>
|
|
75
|
+
|
|
76
|
+
<label
|
|
77
|
+
htmlFor="export-output-dir"
|
|
78
|
+
className="mt-4 flex items-center gap-1.5 text-[11.5px] font-medium text-foreground"
|
|
79
|
+
>
|
|
80
|
+
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
|
|
81
|
+
{t('vuln.export.dirLabel')}
|
|
82
|
+
</label>
|
|
83
|
+
<input
|
|
84
|
+
id="export-output-dir"
|
|
85
|
+
data-testid="export-output-dir"
|
|
86
|
+
value={directory}
|
|
87
|
+
spellCheck={false}
|
|
88
|
+
disabled={pending}
|
|
89
|
+
placeholder={t('vuln.export.dirPlaceholder')}
|
|
90
|
+
onChange={(event) => {
|
|
91
|
+
setDirectory(event.target.value)
|
|
92
|
+
if (invalid) setInvalid(false)
|
|
93
|
+
}}
|
|
94
|
+
onKeyDown={(event) => {
|
|
95
|
+
if (event.key === 'Enter') confirm()
|
|
96
|
+
}}
|
|
97
|
+
className="mt-1.5 h-9 w-full rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
98
|
+
/>
|
|
99
|
+
<p className={invalid ? 'mt-1.5 text-[10.5px] text-red-700' : 'mt-1.5 text-[10.5px] text-muted-foreground'}>
|
|
100
|
+
{invalid ? t('vuln.export.dirInvalid') : t('vuln.export.dirHint')}
|
|
101
|
+
</p>
|
|
102
|
+
|
|
103
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
104
|
+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={pending}>
|
|
105
|
+
{t('common.cancel')}
|
|
106
|
+
</Button>
|
|
107
|
+
<Button size="sm" onClick={confirm} disabled={pending} className="gap-1.5">
|
|
108
|
+
{pending ? (
|
|
109
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
110
|
+
) : (
|
|
111
|
+
<Download aria-hidden="true" className="h-3.5 w-3.5" />
|
|
112
|
+
)}
|
|
113
|
+
{pending ? t('vuln.detail.exporting') : t('vuln.export.confirm')}
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
</DialogPrimitive.Content>
|
|
117
|
+
</DialogPrimitive.Portal>
|
|
118
|
+
</DialogPrimitive.Root>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -27,6 +27,7 @@ import { EmptyState } from '@renderer/components/common/EmptyState'
|
|
|
27
27
|
import { MarkdownText } from '@renderer/components/common/MarkdownText'
|
|
28
28
|
import { VulnSummary } from './VulnSummary'
|
|
29
29
|
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
|
30
|
+
import { ExportReportDialog } from './ExportReportDialog'
|
|
30
31
|
import { apiUnavailableMessage, getApi, toErrorMessage } from '@renderer/lib/api'
|
|
31
32
|
import { getPrefetchedVulnerability } from '@renderer/lib/vulnPreload'
|
|
32
33
|
import { formatDateTime, formatVulnType } from '@renderer/lib/format'
|
|
@@ -137,6 +138,7 @@ export function VulnDetail({
|
|
|
137
138
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
|
138
139
|
const [deleting, setDeleting] = useState(false)
|
|
139
140
|
const [exporting, setExporting] = useState(false)
|
|
141
|
+
const [exportOpen, setExportOpen] = useState(false)
|
|
140
142
|
const [activeTab, setActiveTab] = useState('steps')
|
|
141
143
|
|
|
142
144
|
const { t, lang, statusLabel } = useI18n()
|
|
@@ -252,7 +254,7 @@ export function VulnDetail({
|
|
|
252
254
|
}
|
|
253
255
|
}
|
|
254
256
|
|
|
255
|
-
const handleExport = async () => {
|
|
257
|
+
const handleExport = async (outputDir?: string) => {
|
|
256
258
|
if (!detail || loading) return
|
|
257
259
|
const api = getApi()
|
|
258
260
|
if (!api) {
|
|
@@ -261,12 +263,19 @@ export function VulnDetail({
|
|
|
261
263
|
}
|
|
262
264
|
setExporting(true)
|
|
263
265
|
try {
|
|
264
|
-
const result = await api.exportVulnerabilities([detail.id])
|
|
265
|
-
if (!result.cancelled)
|
|
266
|
+
const result = await api.exportVulnerabilities([detail.id], outputDir)
|
|
267
|
+
if (!result.cancelled) {
|
|
268
|
+
const savedPath = result.savedPaths?.[0]
|
|
269
|
+
toast.success(
|
|
270
|
+
t('vuln.toast.exported'),
|
|
271
|
+
savedPath ? { description: t('vuln.toast.exportedPath', { path: savedPath }) } : undefined
|
|
272
|
+
)
|
|
273
|
+
}
|
|
266
274
|
} catch (cause) {
|
|
267
275
|
toast.error(t('vuln.toast.exportFailed', { error: toErrorMessage(cause, t) }))
|
|
268
276
|
} finally {
|
|
269
277
|
setExporting(false)
|
|
278
|
+
setExportOpen(false)
|
|
270
279
|
}
|
|
271
280
|
}
|
|
272
281
|
|
|
@@ -289,7 +298,7 @@ export function VulnDetail({
|
|
|
289
298
|
variant="ghost"
|
|
290
299
|
size="sm"
|
|
291
300
|
data-testid="export-vuln-md"
|
|
292
|
-
onClick={() =>
|
|
301
|
+
onClick={() => setExportOpen(true)}
|
|
293
302
|
disabled={loading || exporting}
|
|
294
303
|
className="h-7 gap-1.5 px-2 text-[11px]"
|
|
295
304
|
>
|
|
@@ -614,6 +623,14 @@ export function VulnDetail({
|
|
|
614
623
|
onConfirm={() => void handleDelete()}
|
|
615
624
|
/>
|
|
616
625
|
) : null}
|
|
626
|
+
|
|
627
|
+
<ExportReportDialog
|
|
628
|
+
open={exportOpen}
|
|
629
|
+
count={1}
|
|
630
|
+
pending={exporting}
|
|
631
|
+
onOpenChange={setExportOpen}
|
|
632
|
+
onConfirm={(outputDir) => void handleExport(outputDir)}
|
|
633
|
+
/>
|
|
617
634
|
</>
|
|
618
635
|
)
|
|
619
636
|
}
|
|
@@ -91,6 +91,14 @@ export const vulnMessages: MessageBundle = {
|
|
|
91
91
|
'vuln.copy.codeBlockDone': '已复制代码块',
|
|
92
92
|
'vuln.toast.exportedMany': '已导出 {count} 份 Markdown 报告',
|
|
93
93
|
'vuln.toast.exported': 'Markdown 报告已导出',
|
|
94
|
+
'vuln.toast.exportedPath': '已保存到:{path}',
|
|
95
|
+
'vuln.export.title': '导出 Markdown 报告',
|
|
96
|
+
'vuln.export.description': '共 {count} 份报告。可指定导出目录,留空则使用默认目录。',
|
|
97
|
+
'vuln.export.dirLabel': '导出目录',
|
|
98
|
+
'vuln.export.dirPlaceholder': '例如 C:\\Users\\you\\Downloads',
|
|
99
|
+
'vuln.export.dirHint': '留空:~/.cortex/exports/<时间戳>/;支持绝对路径或 ~ 开头路径。',
|
|
100
|
+
'vuln.export.dirInvalid': '请输入绝对路径(如 C:\\Reports 或 ~/Reports)',
|
|
101
|
+
'vuln.export.confirm': '导出',
|
|
94
102
|
'vuln.toast.exportFailed': '导出失败:{error}',
|
|
95
103
|
'vuln.toast.deleted': '已删除 {id}',
|
|
96
104
|
'vuln.toast.bulkDeleted': '已删除 {count} 条漏洞',
|
|
@@ -201,6 +209,14 @@ export const vulnMessages: MessageBundle = {
|
|
|
201
209
|
'vuln.copy.codeBlockDone': 'Code block copied',
|
|
202
210
|
'vuln.toast.exportedMany': 'Exported {count} Markdown reports',
|
|
203
211
|
'vuln.toast.exported': 'Markdown report exported',
|
|
212
|
+
'vuln.toast.exportedPath': 'Saved to: {path}',
|
|
213
|
+
'vuln.export.title': 'Export Markdown report',
|
|
214
|
+
'vuln.export.description': '{count} report(s). Choose an output directory, or leave empty to use the default.',
|
|
215
|
+
'vuln.export.dirLabel': 'Output directory',
|
|
216
|
+
'vuln.export.dirPlaceholder': 'e.g. C:\\Users\\you\\Downloads',
|
|
217
|
+
'vuln.export.dirHint': 'Empty: ~/.cortex/exports/<timestamp>/; absolute or ~-prefixed paths are accepted.',
|
|
218
|
+
'vuln.export.dirInvalid': 'Enter an absolute path (e.g. C:\\Reports or ~/Reports)',
|
|
219
|
+
'vuln.export.confirm': 'Export',
|
|
204
220
|
'vuln.toast.exportFailed': 'Export failed: {error}',
|
|
205
221
|
'vuln.toast.deleted': 'Deleted {id}',
|
|
206
222
|
'vuln.toast.bulkDeleted': 'Deleted {count} vulnerabilities',
|
|
@@ -11,6 +11,23 @@ export interface RepeaterRequestCandidate {
|
|
|
11
11
|
|
|
12
12
|
const REQUEST_LINE = /^([A-Z]+)\s+(\S+)\s+HTTP\/\d(?:\.\d)?$/
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Raw report requests carry only the origin-form path (`GET /api/x HTTP/1.1`),
|
|
16
|
+
* but the Host send API requires an absolute URL. Resolve against the finding's
|
|
17
|
+
* target so http-only targets stay http and https targets stay https.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveRepeaterUrl(rawUrl: string, targetUrl: string): string {
|
|
20
|
+
const value = rawUrl.trim()
|
|
21
|
+
if (/^https?:\/\//i.test(value)) return value
|
|
22
|
+
try {
|
|
23
|
+
const target = new URL(targetUrl)
|
|
24
|
+
if (value.startsWith('//')) return `${target.protocol}${value}`
|
|
25
|
+
return new URL(value, target).href
|
|
26
|
+
} catch {
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
function normalizePath(input: string): string {
|
|
15
32
|
try {
|
|
16
33
|
if (/^https?:\/\//i.test(input)) return new URL(input).pathname
|
|
@@ -261,7 +261,10 @@ export function createHttpApi(): CortexApi {
|
|
|
261
261
|
* saves them. Resolves the downloaded file names, or `cancelled` when there
|
|
262
262
|
* was nothing to export (`files: []`).
|
|
263
263
|
*/
|
|
264
|
-
exportVulnerabilities: async (
|
|
264
|
+
exportVulnerabilities: async (
|
|
265
|
+
ids: string[],
|
|
266
|
+
_outputDir?: string
|
|
267
|
+
): Promise<ExportReportsResult> => {
|
|
265
268
|
const response = await request<WebExportResponse>(WEB_API_PATHS.vulnerabilitiesExport, {
|
|
266
269
|
method: 'POST',
|
|
267
270
|
body: ids
|
|
@@ -15,6 +15,7 @@ import { PaginationBar } from '@renderer/components/vuln/PaginationBar'
|
|
|
15
15
|
import { VulnTable } from '@renderer/components/vuln/VulnTable'
|
|
16
16
|
import { VulnDetail } from '@renderer/components/vuln/VulnDetail'
|
|
17
17
|
import { BulkDeleteDialog } from '@renderer/components/vuln/BulkDeleteDialog'
|
|
18
|
+
import { ExportReportDialog } from '@renderer/components/vuln/ExportReportDialog'
|
|
18
19
|
import { InternalPage } from '@renderer/pages/InternalPage'
|
|
19
20
|
import { Button } from '@renderer/components/ui/button'
|
|
20
21
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
|
@@ -62,6 +63,7 @@ export function DashboardPage({
|
|
|
62
63
|
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
|
63
64
|
const [bulkDeleting, setBulkDeleting] = useState(false)
|
|
64
65
|
const [exporting, setExporting] = useState(false)
|
|
66
|
+
const [exportOpen, setExportOpen] = useState(false)
|
|
65
67
|
|
|
66
68
|
const hasLoadedList = useRef(false)
|
|
67
69
|
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
|
@@ -302,28 +304,34 @@ export function DashboardPage({
|
|
|
302
304
|
[items]
|
|
303
305
|
)
|
|
304
306
|
|
|
305
|
-
const handleExportSelected = async () => {
|
|
307
|
+
const handleExportSelected = async (outputDir?: string) => {
|
|
306
308
|
const ids = [...selectedIds]
|
|
307
309
|
if (ids.length === 0) return
|
|
308
310
|
const api = getApi()
|
|
309
311
|
if (!api) {
|
|
310
312
|
toast.error(apiUnavailableMessage(t))
|
|
313
|
+
setExportOpen(false)
|
|
311
314
|
return
|
|
312
315
|
}
|
|
313
316
|
setExporting(true)
|
|
314
317
|
try {
|
|
315
|
-
const result = await api.exportVulnerabilities(ids)
|
|
318
|
+
const result = await api.exportVulnerabilities(ids, outputDir)
|
|
319
|
+
const savedPath = result.savedPaths?.[0]
|
|
316
320
|
if (!result.cancelled) {
|
|
317
|
-
|
|
321
|
+
const message =
|
|
318
322
|
result.files.length > 1
|
|
319
323
|
? t('vuln.toast.exportedMany', { count: result.files.length })
|
|
320
324
|
: t('vuln.toast.exported')
|
|
325
|
+
toast.success(
|
|
326
|
+
message,
|
|
327
|
+
savedPath ? { description: t('vuln.toast.exportedPath', { path: savedPath }) } : undefined
|
|
321
328
|
)
|
|
322
329
|
}
|
|
323
330
|
} catch (cause) {
|
|
324
331
|
toast.error(t('vuln.toast.exportFailed', { error: toErrorMessage(cause, t) }))
|
|
325
332
|
} finally {
|
|
326
333
|
setExporting(false)
|
|
334
|
+
setExportOpen(false)
|
|
327
335
|
}
|
|
328
336
|
}
|
|
329
337
|
|
|
@@ -439,7 +447,7 @@ export function DashboardPage({
|
|
|
439
447
|
<Button
|
|
440
448
|
variant="outline"
|
|
441
449
|
size="sm"
|
|
442
|
-
onClick={() =>
|
|
450
|
+
onClick={() => setExportOpen(true)}
|
|
443
451
|
disabled={exporting}
|
|
444
452
|
className="gap-1.5"
|
|
445
453
|
>
|
|
@@ -522,6 +530,14 @@ export function DashboardPage({
|
|
|
522
530
|
onOpenChange={setBulkConfirmOpen}
|
|
523
531
|
onConfirm={() => void handleBulkDelete()}
|
|
524
532
|
/>
|
|
533
|
+
|
|
534
|
+
<ExportReportDialog
|
|
535
|
+
open={exportOpen}
|
|
536
|
+
count={selectedCount}
|
|
537
|
+
pending={exporting}
|
|
538
|
+
onOpenChange={setExportOpen}
|
|
539
|
+
onConfirm={(outputDir) => void handleExportSelected(outputDir)}
|
|
540
|
+
/>
|
|
525
541
|
</>
|
|
526
542
|
)
|
|
527
543
|
}
|
|
@@ -158,8 +158,12 @@ export interface CortexApi extends PocApi {
|
|
|
158
158
|
updateVulnerabilityStatus(input: UpdateVulnStatusRequest): Promise<Vulnerability>
|
|
159
159
|
deleteVulnerability(id: string): Promise<DeleteVulnResult>
|
|
160
160
|
deleteVulnerabilities(ids: string[]): Promise<DeleteVulnsResult>
|
|
161
|
-
/**
|
|
162
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Export one or more vulnerability reports as Markdown. `outputDir` is an
|
|
163
|
+
* absolute directory chosen by the user; omitted/empty means the Host default
|
|
164
|
+
* (`~/.cortex/exports/<timestamp>/`).
|
|
165
|
+
*/
|
|
166
|
+
exportVulnerabilities(ids: string[], outputDir?: string): Promise<ExportReportsResult>
|
|
163
167
|
listAssets(filters?: AssetFilters): Promise<AssetListItem[]>
|
|
164
168
|
getAssetDetail(hostPort: string): Promise<AssetContext | null>
|
|
165
169
|
getAssetRelatedVulnerabilities(hostPort: string): Promise<VulnerabilityListItem[]>
|
|
@@ -1020,8 +1020,10 @@ export interface DeleteVulnsResult {
|
|
|
1020
1020
|
|
|
1021
1021
|
export interface ExportReportsResult {
|
|
1022
1022
|
cancelled: boolean
|
|
1023
|
-
/**
|
|
1023
|
+
/** File names (or absolute paths for desktop transports) of exported reports. */
|
|
1024
1024
|
files: string[]
|
|
1025
|
+
/** DSH Host also writes reports under ~/.cortex/exports and returns them here. */
|
|
1026
|
+
savedPaths?: string[]
|
|
1025
1027
|
}
|
|
1026
1028
|
|
|
1027
1029
|
export interface SeedDemoResult {
|
|
@@ -72,6 +72,8 @@ export interface WebExportFile {
|
|
|
72
72
|
/** Response of `POST /api/vulns/export`; the browser downloads these, nothing is written server-side. */
|
|
73
73
|
export interface WebExportResponse {
|
|
74
74
|
files: WebExportFile[]
|
|
75
|
+
/** Optional Host-side saved copies (`~/.cortex/exports/...`) for desktop builds. */
|
|
76
|
+
savedPaths?: string[]
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
/**
|
package/src/host/cortex/vulns.js
CHANGED
|
@@ -34,6 +34,9 @@ import {
|
|
|
34
34
|
} from '../http.js'
|
|
35
35
|
import * as dto from '../dto.js'
|
|
36
36
|
import { buildVulnReport, reportFilename } from '../reports.js'
|
|
37
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
38
|
+
import { homedir } from 'node:os'
|
|
39
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
37
40
|
|
|
38
41
|
const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info']
|
|
39
42
|
const STATUSES = ['Confirmed', 'Inconclusive', 'Retest_Passed']
|
|
@@ -496,8 +499,28 @@ export async function handleDeleteMany({ req, res, holder, bumpRev }) {
|
|
|
496
499
|
export async function handleExport({ req, res, holder, bumpRev }) {
|
|
497
500
|
const body = await readJsonBody(req)
|
|
498
501
|
const ids = requireIdList(body, 'ids', MAX_EXPORT_IDS)
|
|
502
|
+
/** Optional user-selected output directory (absolute path, `~` allowed). */
|
|
503
|
+
let requestedDir
|
|
504
|
+
if (body.outputDir !== undefined) {
|
|
505
|
+
if (typeof body.outputDir !== 'string') {
|
|
506
|
+
throw badRequest('outputDir must be a string', { field: 'outputDir' })
|
|
507
|
+
}
|
|
508
|
+
const raw = body.outputDir.trim()
|
|
509
|
+
if (raw.length > 1000) {
|
|
510
|
+
throw badRequest('outputDir is too long', { field: 'outputDir', max: 1000 })
|
|
511
|
+
}
|
|
512
|
+
if (raw.length > 0) {
|
|
513
|
+
const expanded = raw.startsWith('~')
|
|
514
|
+
? join(homedir(), raw.replace(/^~[/\\]?/, ''))
|
|
515
|
+
: raw
|
|
516
|
+
if (!isAbsolute(expanded)) {
|
|
517
|
+
throw badRequest('outputDir must be an absolute path', { field: 'outputDir' })
|
|
518
|
+
}
|
|
519
|
+
requestedDir = resolve(expanded)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
499
522
|
if (ids.length === 0) {
|
|
500
|
-
sendJson(res, 200, { cancelled: true, files: [] })
|
|
523
|
+
sendJson(res, 200, { cancelled: true, files: [], savedPaths: [] })
|
|
501
524
|
return
|
|
502
525
|
}
|
|
503
526
|
const db = holder.db
|
|
@@ -525,9 +548,43 @@ export async function handleExport({ req, res, holder, bumpRev }) {
|
|
|
525
548
|
content: buildVulnReport(detail, related),
|
|
526
549
|
})
|
|
527
550
|
}
|
|
551
|
+
// The DSH desktop WebView may block browser downloads, so the Host also
|
|
552
|
+
// writes the Markdown reports to ~/.cortex/exports/<timestamp>/ and returns
|
|
553
|
+
// the absolute paths. The renderer still attempts a normal download, but the
|
|
554
|
+
// toast can then point at a real file that always exists.
|
|
555
|
+
const savedPaths = []
|
|
556
|
+
if (files.length > 0) {
|
|
557
|
+
try {
|
|
558
|
+
const exportRoot = process.env.CORTEX_EXPORT_DIR || join(homedir(), '.cortex', 'exports')
|
|
559
|
+
const stamp = new Date()
|
|
560
|
+
.toISOString()
|
|
561
|
+
.replace(/[-:]/g, '')
|
|
562
|
+
.replace(/\..+$/, '')
|
|
563
|
+
.replace('T', '-')
|
|
564
|
+
const exportDir = requestedDir ?? join(exportRoot, stamp)
|
|
565
|
+
mkdirSync(exportDir, { recursive: true })
|
|
566
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
567
|
+
const file = files[index]
|
|
568
|
+
const safeName = String(file.filename).replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').slice(0, 120) || file.id
|
|
569
|
+
const baseName = safeName.endsWith('.md') ? safeName : `${safeName}.md`
|
|
570
|
+
const target = join(exportDir, files.length > 1 ? `${String(index + 1).padStart(2, '0')}-${baseName}` : baseName)
|
|
571
|
+
writeFileSync(target, file.content, 'utf8')
|
|
572
|
+
savedPaths.push(target)
|
|
573
|
+
}
|
|
574
|
+
} catch (error) {
|
|
575
|
+
// Filesystem failure must not break the export response; the inline
|
|
576
|
+
// files still let the browser download attempt proceed. A selected
|
|
577
|
+
// directory failure is reported so the user can correct the path.
|
|
578
|
+
if (requestedDir !== undefined) {
|
|
579
|
+
throw badRequest(`cannot write to outputDir: ${error instanceof Error ? error.message : String(error)}`, {
|
|
580
|
+
field: 'outputDir',
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
528
585
|
// Export is a read-only projection: no revision bump.
|
|
529
586
|
void bumpRev
|
|
530
|
-
sendJson(res, 200, { cancelled: files.length === 0, files })
|
|
587
|
+
sendJson(res, 200, { cancelled: files.length === 0, files, savedPaths })
|
|
531
588
|
}
|
|
532
589
|
|
|
533
590
|
export { SEVERITIES, STATUSES, REALM_FILTERS, VULN_ORDER }
|