@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.

@@ -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': '导出 Markdown 报告',
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 Markdown report',
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
- _outputDir?: string
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 as Markdown. `outputDir` is an
163
- * absolute directory chosen by the user; omitted/empty means the Host default
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[], outputDir?: string): Promise<ExportReportsResult>
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
+ }
@@ -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
+ }
@@ -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
- files.push({
546
- id,
547
- filename: reportFilename(row.vuln_name && /^[\x20-\x7e]+$/.test(String(row.vuln_name)) ? String(row.vuln_name) : id),
548
- content: buildVulnReport(detail, related),
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 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.
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 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')
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
- // files still let the browser download attempt proceed. A selected
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 }