@lievclouds/dsh-cortex 0.4.4 → 0.5.4
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 +327 -327
- package/package.json +5 -1
- package/src/cortex-ui/dsh/api.ts +5 -2
- package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +45 -14
- 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/retest.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
- package/src/cortex-ui/renderer/lib/retestResponse.ts +17 -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/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 +7 -0
- package/src/host/cortex/settings.js +54 -0
- package/src/host/cortex/vulns.js +36 -13
- package/src/host/reports-office.js +341 -0
|
@@ -38,6 +38,7 @@ export const retestMessages: MessageBundle = {
|
|
|
38
38
|
'retest.repeater.stepN': '步骤{n}',
|
|
39
39
|
'retest.repeater.followRedirect': '跟随重定向',
|
|
40
40
|
'retest.repeater.viewRaw': '原始',
|
|
41
|
+
'retest.repeater.formatJson': '格式化',
|
|
41
42
|
'retest.repeater.viewHtml': '渲染',
|
|
42
43
|
'retest.repeater.htmlPreview': 'HTML 渲染预览',
|
|
43
44
|
'retest.repeater.emptyResponse': '发送请求后显示原始响应',
|
|
@@ -486,6 +487,7 @@ export const retestMessages: MessageBundle = {
|
|
|
486
487
|
'retest.repeater.stepN': 'Step {n}',
|
|
487
488
|
'retest.repeater.followRedirect': 'Follow redirect',
|
|
488
489
|
'retest.repeater.viewRaw': 'Raw',
|
|
490
|
+
'retest.repeater.formatJson': 'Format',
|
|
489
491
|
'retest.repeater.viewHtml': 'Render',
|
|
490
492
|
'retest.repeater.htmlPreview': 'Rendered HTML preview',
|
|
491
493
|
'retest.repeater.emptyResponse': 'Send a request to inspect the raw response',
|
|
@@ -92,13 +92,23 @@ export const vulnMessages: MessageBundle = {
|
|
|
92
92
|
'vuln.toast.exportedMany': '已导出 {count} 份 Markdown 报告',
|
|
93
93
|
'vuln.toast.exported': 'Markdown 报告已导出',
|
|
94
94
|
'vuln.toast.exportedPath': '已保存到:{path}',
|
|
95
|
-
'vuln.export.title': '
|
|
96
|
-
'vuln.export.description': '共 {count} 份报告。可指定导出目录,留空则使用默认目录。',
|
|
95
|
+
'vuln.export.title': '导出报告',
|
|
97
96
|
'vuln.export.dirLabel': '导出目录',
|
|
97
|
+
'vuln.export.browse': '选择文件夹',
|
|
98
98
|
'vuln.export.dirPlaceholder': '例如 C:\\Users\\you\\Downloads',
|
|
99
|
-
'vuln.export.dirHint': '留空:~/.cortex/exports/<时间戳>/;支持绝对路径或 ~ 开头路径。',
|
|
100
99
|
'vuln.export.dirInvalid': '请输入绝对路径(如 C:\\Reports 或 ~/Reports)',
|
|
101
100
|
'vuln.export.confirm': '导出',
|
|
101
|
+
'vuln.export.formatLabel': '报告格式',
|
|
102
|
+
'vuln.export.formatMd': 'Markdown (.md)',
|
|
103
|
+
'vuln.export.formatDocx': 'Word (.docx)',
|
|
104
|
+
'vuln.export.formatPdf': 'PDF (.pdf)',
|
|
105
|
+
'vuln.dirPicker.title': '选择导出文件夹',
|
|
106
|
+
'vuln.dirPicker.go': '前往',
|
|
107
|
+
'vuln.dirPicker.up': '上一级',
|
|
108
|
+
'vuln.dirPicker.loading': '加载中…',
|
|
109
|
+
'vuln.dirPicker.empty': '该目录下没有子文件夹',
|
|
110
|
+
'vuln.dirPicker.error': '读取目录失败:{error}',
|
|
111
|
+
'vuln.dirPicker.select': '使用此文件夹',
|
|
102
112
|
'vuln.toast.exportFailed': '导出失败:{error}',
|
|
103
113
|
'vuln.toast.deleted': '已删除 {id}',
|
|
104
114
|
'vuln.toast.bulkDeleted': '已删除 {count} 条漏洞',
|
|
@@ -210,13 +220,23 @@ export const vulnMessages: MessageBundle = {
|
|
|
210
220
|
'vuln.toast.exportedMany': 'Exported {count} Markdown reports',
|
|
211
221
|
'vuln.toast.exported': 'Markdown report exported',
|
|
212
222
|
'vuln.toast.exportedPath': 'Saved to: {path}',
|
|
213
|
-
'vuln.export.title': 'Export
|
|
214
|
-
'vuln.export.description': '{count} report(s). Choose an output directory, or leave empty to use the default.',
|
|
223
|
+
'vuln.export.title': 'Export report',
|
|
215
224
|
'vuln.export.dirLabel': 'Output directory',
|
|
225
|
+
'vuln.export.browse': 'Choose folder',
|
|
216
226
|
'vuln.export.dirPlaceholder': 'e.g. C:\\Users\\you\\Downloads',
|
|
217
|
-
'vuln.export.dirHint': 'Empty: ~/.cortex/exports/<timestamp>/; absolute or ~-prefixed paths are accepted.',
|
|
218
227
|
'vuln.export.dirInvalid': 'Enter an absolute path (e.g. C:\\Reports or ~/Reports)',
|
|
219
228
|
'vuln.export.confirm': 'Export',
|
|
229
|
+
'vuln.export.formatLabel': 'Report format',
|
|
230
|
+
'vuln.export.formatMd': 'Markdown (.md)',
|
|
231
|
+
'vuln.export.formatDocx': 'Word (.docx)',
|
|
232
|
+
'vuln.export.formatPdf': 'PDF (.pdf)',
|
|
233
|
+
'vuln.dirPicker.title': 'Choose export folder',
|
|
234
|
+
'vuln.dirPicker.go': 'Go',
|
|
235
|
+
'vuln.dirPicker.up': 'Up',
|
|
236
|
+
'vuln.dirPicker.loading': 'Loading…',
|
|
237
|
+
'vuln.dirPicker.empty': 'No subfolders in this folder',
|
|
238
|
+
'vuln.dirPicker.error': 'Failed to read folder: {error}',
|
|
239
|
+
'vuln.dirPicker.select': 'Use this folder',
|
|
220
240
|
'vuln.toast.exportFailed': 'Export failed: {error}',
|
|
221
241
|
'vuln.toast.deleted': 'Deleted {id}',
|
|
222
242
|
'vuln.toast.bulkDeleted': 'Deleted {count} vulnerabilities',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response formatting helpers for the retest repeater (Yakit/Burp-style JSON
|
|
3
|
+
* view). Kept away from the TSX component so they are unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Pretty-print JSON bodies; null when the body is not JSON. */
|
|
7
|
+
export function prettyJsonBody(body: string, contentType: string): string | null {
|
|
8
|
+
const trimmed = body.trim()
|
|
9
|
+
if (trimmed.length === 0) return null
|
|
10
|
+
if (!/json/i.test(contentType) && !/^[{[]/.test(trimmed)) return null
|
|
11
|
+
if (trimmed.length > 2_000_000) return null
|
|
12
|
+
try {
|
|
13
|
+
return JSON.stringify(JSON.parse(body), null, 2)
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -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
|
)
|
|
@@ -159,11 +159,10 @@ export interface CortexApi extends PocApi {
|
|
|
159
159
|
deleteVulnerability(id: string): Promise<DeleteVulnResult>
|
|
160
160
|
deleteVulnerabilities(ids: string[]): Promise<DeleteVulnsResult>
|
|
161
161
|
/**
|
|
162
|
-
* Export one or more vulnerability reports
|
|
163
|
-
* absolute
|
|
164
|
-
* (`~/.cortex/exports/<timestamp>/`).
|
|
162
|
+
* Export one or more vulnerability reports. Options choose the output
|
|
163
|
+
* directory (absolute, `~` allowed) and the format (`md` default, `docx`, `pdf`).
|
|
165
164
|
*/
|
|
166
|
-
exportVulnerabilities(ids: string[],
|
|
165
|
+
exportVulnerabilities(ids: string[], options?: ExportReportOptions): Promise<ExportReportsResult>
|
|
167
166
|
listAssets(filters?: AssetFilters): Promise<AssetListItem[]>
|
|
168
167
|
getAssetDetail(hostPort: string): Promise<AssetContext | null>
|
|
169
168
|
getAssetRelatedVulnerabilities(hostPort: string): Promise<VulnerabilityListItem[]>
|
|
@@ -1018,6 +1018,15 @@ export interface DeleteVulnsResult {
|
|
|
1018
1018
|
deleted: number
|
|
1019
1019
|
}
|
|
1020
1020
|
|
|
1021
|
+
export type ExportReportFormat = 'md' | 'docx' | 'pdf'
|
|
1022
|
+
|
|
1023
|
+
export interface ExportReportOptions {
|
|
1024
|
+
/** Absolute output directory chosen by the user; empty means the default. */
|
|
1025
|
+
outputDir?: string
|
|
1026
|
+
/** Markdown (default), Word or PDF. */
|
|
1027
|
+
format?: ExportReportFormat
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1021
1030
|
export interface ExportReportsResult {
|
|
1022
1031
|
cancelled: boolean
|
|
1023
1032
|
/** File names (or absolute paths for desktop transports) of exported reports. */
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side directory browser for the export path picker.
|
|
3
|
+
*
|
|
4
|
+
* The DSH desktop WebView cannot expose absolute paths through the browser's
|
|
5
|
+
* File System Access API, so the export dialog asks the Host to list folders and
|
|
6
|
+
* the user picks one. Read-only: no filesystem writes happen here.
|
|
7
|
+
*
|
|
8
|
+
* GET /cortex-api/cortex/fs/list?path=<absolute or ~-prefixed> ->
|
|
9
|
+
* { path, parent, directories: [{ name, path }], home }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
|
|
15
|
+
import { badRequest, sendJson } from '../http.js'
|
|
16
|
+
|
|
17
|
+
const MAX_PATH_LENGTH = 2000
|
|
18
|
+
const MAX_DIRECTORIES = 500
|
|
19
|
+
|
|
20
|
+
function expandTilde(value) {
|
|
21
|
+
if (!value.startsWith('~')) return value
|
|
22
|
+
return join(homedir(), value.replace(/^~[/\\]?/, ''))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function queryPath(searchParams) {
|
|
26
|
+
const raw = searchParams && typeof searchParams.get === 'function' ? searchParams.get('path') : null
|
|
27
|
+
if (raw === null || raw === undefined) return homedir()
|
|
28
|
+
const trimmed = String(raw).trim()
|
|
29
|
+
if (trimmed.length === 0) return homedir()
|
|
30
|
+
if (trimmed.length > MAX_PATH_LENGTH) {
|
|
31
|
+
throw badRequest('path is too long', { field: 'path', max: MAX_PATH_LENGTH })
|
|
32
|
+
}
|
|
33
|
+
const expanded = expandTilde(trimmed)
|
|
34
|
+
if (!isAbsolute(expanded)) {
|
|
35
|
+
throw badRequest('path must be an absolute path', { field: 'path' })
|
|
36
|
+
}
|
|
37
|
+
return resolve(expanded)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** GET /fs/list — list subdirectories of one folder. */
|
|
41
|
+
export function handleList({ res, searchParams }) {
|
|
42
|
+
const target = queryPath(searchParams)
|
|
43
|
+
let stat
|
|
44
|
+
try {
|
|
45
|
+
stat = statSync(target)
|
|
46
|
+
} catch {
|
|
47
|
+
throw badRequest('directory does not exist', { field: 'path' })
|
|
48
|
+
}
|
|
49
|
+
if (!stat.isDirectory()) {
|
|
50
|
+
throw badRequest('path is not a directory', { field: 'path' })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let entries
|
|
54
|
+
try {
|
|
55
|
+
entries = readdirSync(target, { withFileTypes: true })
|
|
56
|
+
} catch {
|
|
57
|
+
throw badRequest('cannot read directory', { field: 'path' })
|
|
58
|
+
}
|
|
59
|
+
const directories = entries
|
|
60
|
+
.filter((entry) => entry.isDirectory())
|
|
61
|
+
.map((entry) => ({ name: entry.name, path: join(target, entry.name) }))
|
|
62
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
63
|
+
.slice(0, MAX_DIRECTORIES)
|
|
64
|
+
|
|
65
|
+
const root = parse(target).root
|
|
66
|
+
sendJson(res, 200, {
|
|
67
|
+
path: target,
|
|
68
|
+
parent: target === root ? null : dirname(target),
|
|
69
|
+
directories,
|
|
70
|
+
home: homedir(),
|
|
71
|
+
})
|
|
72
|
+
}
|
package/src/host/cortex/index.js
CHANGED
|
@@ -36,7 +36,9 @@ import {
|
|
|
36
36
|
sendJson,
|
|
37
37
|
} from '../http.js'
|
|
38
38
|
import * as assets from './assets.js'
|
|
39
|
+
import * as fs from './fs.js'
|
|
39
40
|
import * as misc from './misc.js'
|
|
41
|
+
import * as settings from './settings.js'
|
|
40
42
|
import * as repeater from './repeater.js'
|
|
41
43
|
import * as snapshots from './snapshots.js'
|
|
42
44
|
import * as vulns from './vulns.js'
|
|
@@ -114,6 +116,11 @@ const ROUTES = [
|
|
|
114
116
|
{ pattern: /^\/poc\/provider-test$/, methods: { POST: misc.unsupportedHandler }, db: false },
|
|
115
117
|
|
|
116
118
|
{ pattern: /^\/repeater\/send$/, methods: { POST: repeater.handleSend } },
|
|
119
|
+
|
|
120
|
+
// Directory browser for the export-path picker (read-only).
|
|
121
|
+
{ pattern: /^\/fs\/list$/, methods: { GET: fs.handleList }, db: false },
|
|
122
|
+
// Remembered export defaults for the export dialog (plugin-local settings).
|
|
123
|
+
{ pattern: /^\/export\/settings$/, methods: { GET: settings.handleGetExportSettings }, db: false },
|
|
117
124
|
]
|
|
118
125
|
|
|
119
126
|
/* ------------------------------------------------------------------------- */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small plugin-local settings store (JSON file under ~/.cortex).
|
|
3
|
+
*
|
|
4
|
+
* Currently remembers the last successful export directory so the export dialog
|
|
5
|
+
* can default to it on the next export. Kept out of Cortex's own tables/config
|
|
6
|
+
* so the desktop app and schema stay untouched.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { sendJson } from '../http.js'
|
|
13
|
+
|
|
14
|
+
function settingsPath() {
|
|
15
|
+
return process.env.CORTEX_SETTINGS_FILE || join(homedir(), '.cortex', 'plugin-settings.json')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readSettings() {
|
|
19
|
+
const file = settingsPath()
|
|
20
|
+
if (!existsSync(file)) return {}
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
23
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
24
|
+
} catch {
|
|
25
|
+
return {}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeSettings(settings) {
|
|
30
|
+
const file = settingsPath()
|
|
31
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
32
|
+
writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getLastExportDir() {
|
|
36
|
+
const value = readSettings().exportDir
|
|
37
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Best-effort: a settings write failure must never fail an export. */
|
|
41
|
+
export function rememberExportDir(outputDir) {
|
|
42
|
+
try {
|
|
43
|
+
const settings = readSettings()
|
|
44
|
+
settings.exportDir = outputDir
|
|
45
|
+
writeSettings(settings)
|
|
46
|
+
} catch {
|
|
47
|
+
/* ignore */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** GET /export/settings -> { outputDir: string | null } */
|
|
52
|
+
export function handleGetExportSettings({ res }) {
|
|
53
|
+
sendJson(res, 200, { outputDir: getLastExportDir() })
|
|
54
|
+
}
|
package/src/host/cortex/vulns.js
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
} from '../http.js'
|
|
35
35
|
import * as dto from '../dto.js'
|
|
36
36
|
import { buildVulnReport, reportFilename } from '../reports.js'
|
|
37
|
+
import { renderDocxReport, renderPdfReport } from '../reports-office.js'
|
|
38
|
+
import { rememberExportDir } from './settings.js'
|
|
37
39
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
38
40
|
import { homedir } from 'node:os'
|
|
39
41
|
import { isAbsolute, join, resolve } from 'node:path'
|
|
@@ -49,6 +51,7 @@ const MAX_ID_LENGTH = 100
|
|
|
49
51
|
const MAX_BULK_IDS = 500
|
|
50
52
|
const MAX_HOST_FILTER = 255
|
|
51
53
|
const MAX_EXPORT_IDS = 200
|
|
54
|
+
const EXPORT_FORMATS = ['md', 'docx', 'pdf']
|
|
52
55
|
|
|
53
56
|
const VULN_ORDER = 'ORDER BY created_at DESC, rowid DESC'
|
|
54
57
|
|
|
@@ -499,6 +502,11 @@ export async function handleDeleteMany({ req, res, holder, bumpRev }) {
|
|
|
499
502
|
export async function handleExport({ req, res, holder, bumpRev }) {
|
|
500
503
|
const body = await readJsonBody(req)
|
|
501
504
|
const ids = requireIdList(body, 'ids', MAX_EXPORT_IDS)
|
|
505
|
+
/** Report format: Markdown by default; Word/PDF are generated Host-side. */
|
|
506
|
+
const format = body.format === undefined ? 'md' : String(body.format).trim().toLowerCase()
|
|
507
|
+
if (!EXPORT_FORMATS.includes(format)) {
|
|
508
|
+
throw badRequest('format must be md, docx or pdf', { field: 'format' })
|
|
509
|
+
}
|
|
502
510
|
/** Optional user-selected output directory (absolute path, `~` allowed). */
|
|
503
511
|
let requestedDir
|
|
504
512
|
if (body.outputDir !== undefined) {
|
|
@@ -542,16 +550,24 @@ export async function handleExport({ req, res, holder, bumpRev }) {
|
|
|
542
550
|
targetUrl: item.target_url,
|
|
543
551
|
host: item.host,
|
|
544
552
|
}))
|
|
545
|
-
|
|
546
|
-
id,
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
553
|
+
const baseName = reportFilename(
|
|
554
|
+
row.vuln_name && /^[\x20-\x7e]+$/.test(String(row.vuln_name)) ? String(row.vuln_name) : id,
|
|
555
|
+
).replace(/\.md$/, '')
|
|
556
|
+
const markdown = buildVulnReport(detail, related)
|
|
557
|
+
const title = String(row.vuln_name || id)
|
|
558
|
+
if (format === 'md') {
|
|
559
|
+
files.push({ id, filename: `${baseName}.md`, content: markdown })
|
|
560
|
+
continue
|
|
561
|
+
}
|
|
562
|
+
const buffer =
|
|
563
|
+
format === 'docx'
|
|
564
|
+
? await renderDocxReport(markdown, { title })
|
|
565
|
+
: await renderPdfReport(markdown, { title })
|
|
566
|
+
files.push({ id, filename: `${baseName}.${format}`, format, buffer })
|
|
550
567
|
}
|
|
551
568
|
// The DSH desktop WebView may block browser downloads, so the Host also
|
|
552
|
-
// writes the
|
|
553
|
-
// the absolute paths. The
|
|
554
|
-
// toast can then point at a real file that always exists.
|
|
569
|
+
// writes the reports to the selected directory (or ~/.cortex/exports/<stamp>)
|
|
570
|
+
// and returns the absolute paths. The toast then points at a real file.
|
|
555
571
|
const savedPaths = []
|
|
556
572
|
if (files.length > 0) {
|
|
557
573
|
try {
|
|
@@ -566,14 +582,15 @@ export async function handleExport({ req, res, holder, bumpRev }) {
|
|
|
566
582
|
for (let index = 0; index < files.length; index += 1) {
|
|
567
583
|
const file = files[index]
|
|
568
584
|
const safeName = String(file.filename).replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').slice(0, 120) || file.id
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
writeFileSync(target, file.content, 'utf8')
|
|
585
|
+
const target = join(exportDir, files.length > 1 ? `${String(index + 1).padStart(2, '0')}-${safeName}` : safeName)
|
|
586
|
+
writeFileSync(target, file.buffer ?? file.content, file.buffer ? undefined : 'utf8')
|
|
572
587
|
savedPaths.push(target)
|
|
573
588
|
}
|
|
589
|
+
// Remember the directory the user explicitly chose for the next export.
|
|
590
|
+
if (requestedDir !== undefined) rememberExportDir(exportDir)
|
|
574
591
|
} catch (error) {
|
|
575
592
|
// Filesystem failure must not break the export response; the inline
|
|
576
|
-
//
|
|
593
|
+
// Markdown still lets the browser download attempt proceed. A selected
|
|
577
594
|
// directory failure is reported so the user can correct the path.
|
|
578
595
|
if (requestedDir !== undefined) {
|
|
579
596
|
throw badRequest(`cannot write to outputDir: ${error instanceof Error ? error.message : String(error)}`, {
|
|
@@ -582,9 +599,15 @@ export async function handleExport({ req, res, holder, bumpRev }) {
|
|
|
582
599
|
}
|
|
583
600
|
}
|
|
584
601
|
}
|
|
602
|
+
// Binary formats carry no inline body: the saved file is the deliverable.
|
|
603
|
+
const responseFiles = files.map((file) =>
|
|
604
|
+
file.buffer
|
|
605
|
+
? { id: file.id, filename: file.filename, format: file.format, size: file.buffer.length }
|
|
606
|
+
: { id: file.id, filename: file.filename, content: file.content },
|
|
607
|
+
)
|
|
585
608
|
// Export is a read-only projection: no revision bump.
|
|
586
609
|
void bumpRev
|
|
587
|
-
sendJson(res, 200, { cancelled: files.length === 0, files, savedPaths })
|
|
610
|
+
sendJson(res, 200, { cancelled: files.length === 0, files: responseFiles, savedPaths })
|
|
588
611
|
}
|
|
589
612
|
|
|
590
613
|
export { SEVERITIES, STATUSES, REALM_FILTERS, VULN_ORDER }
|