@lievclouds/dsh-cortex 0.4.3 → 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.

@@ -0,0 +1,341 @@
1
+ /**
2
+ * Office-format report generation for the ported Cortex UI.
3
+ *
4
+ * The Markdown report built by `reports.js` is the single source of truth; this
5
+ * module renders the same content as a Word document (docx) or a PDF. PDF text
6
+ * is Chinese, so a CJK TTF font from the host OS is embedded (PDFKit cannot
7
+ * embed the system .ttc collections, hence the TTF candidate list).
8
+ *
9
+ * Both renderers are pure Node (no browser APIs) and used by
10
+ * `POST /vulns/export` when `format` is `docx` or `pdf`.
11
+ */
12
+
13
+ import { existsSync } from 'node:fs'
14
+ import PDFDocument from 'pdfkit'
15
+ import {
16
+ BorderStyle,
17
+ Document,
18
+ HeadingLevel,
19
+ Packer,
20
+ Paragraph,
21
+ TextRun,
22
+ } from 'docx'
23
+
24
+ /** Common single-face CJK TrueType fonts; PDFKit rejects .ttc collections. */
25
+ const CJK_FONT_CANDIDATES = [
26
+ 'C:\\Windows\\Fonts\\simhei.ttf',
27
+ 'C:\\Windows\\Fonts\\simkai.ttf',
28
+ 'C:\\Windows\\Fonts\\simfang.ttf',
29
+ 'C:\\Windows\\Fonts\\Deng.ttf',
30
+ 'C:\\Windows\\Fonts\\msyh.ttf',
31
+ '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
32
+ '/Library/Fonts/Arial Unicode.ttf',
33
+ '/usr/share/fonts/truetype/arphic/uming.ttc',
34
+ ]
35
+
36
+ const DOCX_BODY_FONT = 'Microsoft YaHei'
37
+ const DOCX_MONO_FONT = 'Consolas'
38
+ const PDF_PAGE_MARGIN = 56
39
+
40
+ export function findCjkFont() {
41
+ const explicit = process.env.CORTEX_PDF_FONT
42
+ if (explicit && existsSync(explicit)) return explicit
43
+ for (const candidate of CJK_FONT_CANDIDATES) {
44
+ if (existsSync(candidate)) return candidate
45
+ }
46
+ return null
47
+ }
48
+
49
+ /* ------------------------------------------------------------ markdown -- */
50
+
51
+ /**
52
+ * Parse the report Markdown into simple blocks. Covers exactly what
53
+ * `buildVulnReport` emits plus fenced HTTP/JSON code inside reproduction steps.
54
+ * @param {string} markdown
55
+ * @returns {Array<{type:'heading'|'paragraph'|'list'|'code'|'hr', level?:number, text?:string, lines?:string[]}>}
56
+ */
57
+ export function parseReportMarkdown(markdown) {
58
+ const lines = String(markdown ?? '')
59
+ .replace(/\r\n/g, '\n')
60
+ .split('\n')
61
+ const blocks = []
62
+ let index = 0
63
+ while (index < lines.length) {
64
+ const line = lines[index]
65
+ if (line.trim() === '') {
66
+ index += 1
67
+ continue
68
+ }
69
+ if (/^```/.test(line)) {
70
+ const code = []
71
+ index += 1
72
+ while (index < lines.length && !/^```/.test(lines[index])) {
73
+ code.push(lines[index])
74
+ index += 1
75
+ }
76
+ index += 1
77
+ blocks.push({ type: 'code', text: code.join('\n') })
78
+ continue
79
+ }
80
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
81
+ if (heading) {
82
+ blocks.push({ type: 'heading', level: heading[1].length, text: heading[2].trim() })
83
+ index += 1
84
+ continue
85
+ }
86
+ if (/^\s*[-*]\s+/.test(line)) {
87
+ const items = []
88
+ while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
89
+ items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim())
90
+ index += 1
91
+ }
92
+ blocks.push({ type: 'list', lines: items })
93
+ continue
94
+ }
95
+ if (/^---+$/.test(line.trim())) {
96
+ blocks.push({ type: 'hr' })
97
+ index += 1
98
+ continue
99
+ }
100
+ const paragraph = []
101
+ while (index < lines.length) {
102
+ const next = lines[index]
103
+ if (
104
+ next.trim() === '' ||
105
+ /^```/.test(next) ||
106
+ /^#{1,6}\s+/.test(next) ||
107
+ /^\s*[-*]\s+/.test(next) ||
108
+ /^---+$/.test(next.trim())
109
+ ) {
110
+ break
111
+ }
112
+ paragraph.push(next.trim())
113
+ index += 1
114
+ }
115
+ blocks.push({ type: 'paragraph', text: paragraph.join(' ') })
116
+ }
117
+ return blocks
118
+ }
119
+
120
+ /** Strip inline Markdown markers for plain-text renderers. */
121
+ export function plainInline(text) {
122
+ return String(text ?? '')
123
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
124
+ .replace(/`([^`]+)`/g, '$1')
125
+ .replace(/\*([^*]+)\*/g, '$1')
126
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
127
+ }
128
+
129
+ /* ---------------------------------------------------------------- docx -- */
130
+
131
+ function docxInlineRuns(text) {
132
+ const runs = []
133
+ const pattern = /(\*\*[^*]+\*\*|`[^`]+`)/g
134
+ let last = 0
135
+ let match
136
+ while ((match = pattern.exec(String(text ?? ''))) !== null) {
137
+ if (match.index > last) {
138
+ runs.push(new TextRun({ text: String(text).slice(last, match.index) }))
139
+ }
140
+ const token = match[0]
141
+ if (token.startsWith('**')) {
142
+ runs.push(new TextRun({ text: token.slice(2, -2), bold: true }))
143
+ } else {
144
+ runs.push(new TextRun({ text: token.slice(1, -1), font: DOCX_MONO_FONT, shading: { fill: 'F3F4F6' } }))
145
+ }
146
+ last = match.index + token.length
147
+ }
148
+ if (last < String(text ?? '').length) runs.push(new TextRun({ text: String(text).slice(last) }))
149
+ return runs.length > 0 ? runs : [new TextRun({ text: '' })]
150
+ }
151
+
152
+ function headingLevel(level) {
153
+ if (level <= 1) return HeadingLevel.HEADING_1
154
+ if (level === 2) return HeadingLevel.HEADING_2
155
+ return HeadingLevel.HEADING_3
156
+ }
157
+
158
+ /**
159
+ * Render the Markdown report as a Word document.
160
+ * @param {string} markdown
161
+ * @param {{ title?: string, author?: string }} [meta]
162
+ * @returns {Promise<Buffer>}
163
+ */
164
+ export async function renderDocxReport(markdown, meta = {}) {
165
+ const children = []
166
+ for (const block of parseReportMarkdown(markdown)) {
167
+ if (block.type === 'heading') {
168
+ children.push(
169
+ new Paragraph({
170
+ heading: headingLevel(block.level),
171
+ spacing: { before: block.level <= 1 ? 0 : 220, after: 120 },
172
+ children: docxInlineRuns(plainInline(block.text)),
173
+ }),
174
+ )
175
+ } else if (block.type === 'paragraph') {
176
+ children.push(
177
+ new Paragraph({
178
+ spacing: { after: 140, line: 320 },
179
+ children: docxInlineRuns(block.text),
180
+ }),
181
+ )
182
+ } else if (block.type === 'list') {
183
+ for (const item of block.lines) {
184
+ children.push(
185
+ new Paragraph({
186
+ bullet: { level: 0 },
187
+ spacing: { after: 80, line: 300 },
188
+ children: docxInlineRuns(item),
189
+ }),
190
+ )
191
+ }
192
+ } else if (block.type === 'code') {
193
+ for (const line of block.text.split('\n')) {
194
+ children.push(
195
+ new Paragraph({
196
+ spacing: { after: 0 },
197
+ shading: { fill: 'F4F5F7' },
198
+ children: [new TextRun({ text: line === '' ? ' ' : line, font: DOCX_MONO_FONT, size: 18 })],
199
+ }),
200
+ )
201
+ }
202
+ children.push(new Paragraph({ spacing: { after: 120 }, children: [] }))
203
+ } else if (block.type === 'hr') {
204
+ children.push(
205
+ new Paragraph({
206
+ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'D1D5DB' } },
207
+ spacing: { after: 120 },
208
+ children: [],
209
+ }),
210
+ )
211
+ }
212
+ }
213
+
214
+ const document = new Document({
215
+ creator: 'Cortex / DSH',
216
+ title: meta.title || 'Cortex vulnerability report',
217
+ styles: {
218
+ default: {
219
+ document: {
220
+ run: { font: DOCX_BODY_FONT, size: 21, color: '1F2937' },
221
+ paragraph: { spacing: { line: 320 } },
222
+ },
223
+ },
224
+ },
225
+ sections: [{ properties: {}, children }],
226
+ })
227
+ return Packer.toBuffer(document)
228
+ }
229
+
230
+ /* ----------------------------------------------------------------- pdf -- */
231
+
232
+ function pdfCodeHeight(doc, text, width) {
233
+ return doc.heightOfString(text, { width, lineGap: 1, fontSize: 8.5 })
234
+ }
235
+
236
+ /**
237
+ * Render the Markdown report as a PDF with an embedded CJK font.
238
+ * @param {string} markdown
239
+ * @param {{ title?: string, author?: string }} [meta]
240
+ * @returns {Promise<Buffer>}
241
+ */
242
+ export async function renderPdfReport(markdown, meta = {}) {
243
+ const fontPath = findCjkFont()
244
+ if (fontPath === null) {
245
+ throw new Error(
246
+ 'PDF 导出需要系统中文字体(TTF):Windows 请安装“黑体/等线/楷体”,或设置环境变量 CORTEX_PDF_FONT 指向一个中文字体文件',
247
+ )
248
+ }
249
+
250
+ const doc = new PDFDocument({
251
+ size: 'A4',
252
+ margins: { top: PDF_PAGE_MARGIN, left: PDF_PAGE_MARGIN, right: PDF_PAGE_MARGIN, bottom: PDF_PAGE_MARGIN + 18 },
253
+ bufferPages: true,
254
+ info: {
255
+ Title: meta.title || 'Cortex vulnerability report',
256
+ Author: meta.author || 'Cortex / DSH',
257
+ },
258
+ })
259
+ const chunks = []
260
+ doc.on('data', (chunk) => chunks.push(chunk))
261
+ const finished = new Promise((resolve, reject) => {
262
+ doc.on('end', () => resolve(Buffer.concat(chunks)))
263
+ doc.on('error', reject)
264
+ })
265
+
266
+ doc.registerFont('body', fontPath)
267
+ const contentWidth = doc.page.width - PDF_PAGE_MARGIN * 2
268
+ const bottom = doc.page.height - PDF_PAGE_MARGIN - 18
269
+
270
+ const ensureSpace = (height) => {
271
+ if (doc.y + height > bottom) doc.addPage()
272
+ }
273
+
274
+ for (const block of parseReportMarkdown(markdown)) {
275
+ if (block.type === 'heading') {
276
+ const size = block.level <= 1 ? 19 : block.level === 2 ? 13.5 : 11.5
277
+ ensureSpace(size + 24)
278
+ doc.moveDown(block.level <= 1 ? 0 : 0.4)
279
+ doc.font('body').fontSize(size).fillColor('#111111').text(plainInline(block.text), {
280
+ width: contentWidth,
281
+ lineGap: 2,
282
+ })
283
+ doc.moveDown(0.35)
284
+ } else if (block.type === 'paragraph') {
285
+ const text = plainInline(block.text)
286
+ ensureSpace(20)
287
+ doc.font('body').fontSize(10).fillColor('#26303C').text(text, {
288
+ width: contentWidth,
289
+ lineGap: 2.5,
290
+ align: 'left',
291
+ })
292
+ doc.moveDown(0.55)
293
+ } else if (block.type === 'list') {
294
+ for (const item of block.lines) {
295
+ ensureSpace(16)
296
+ doc.font('body').fontSize(10).fillColor('#26303C').text(`• ${plainInline(item)}`, {
297
+ width: contentWidth,
298
+ indent: 10,
299
+ lineGap: 2,
300
+ })
301
+ doc.moveDown(0.2)
302
+ }
303
+ doc.moveDown(0.25)
304
+ } else if (block.type === 'code') {
305
+ const text = block.text.length > 0 ? block.text : ' '
306
+ const height = pdfCodeHeight(doc, text, contentWidth - 16) + 10
307
+ ensureSpace(height + 6)
308
+ const top = doc.y
309
+ doc.save().roundedRect(PDF_PAGE_MARGIN, top, contentWidth, height, 4).fill('#F3F4F6').restore()
310
+ doc
311
+ .font('body')
312
+ .fontSize(8.5)
313
+ .fillColor('#1F2937')
314
+ .text(text, PDF_PAGE_MARGIN + 8, top + 5, { width: contentWidth - 16, lineGap: 1 })
315
+ doc.moveDown(0.5)
316
+ } else if (block.type === 'hr') {
317
+ ensureSpace(14)
318
+ doc.moveDown(0.2)
319
+ const y = doc.y
320
+ doc.save().moveTo(PDF_PAGE_MARGIN, y).lineTo(PDF_PAGE_MARGIN + contentWidth, y)
321
+ .lineWidth(0.6).strokeColor('#D1D5DB').stroke().restore()
322
+ doc.moveDown(0.6)
323
+ }
324
+ }
325
+
326
+ const pageCount = doc.bufferedPageRange().count
327
+ for (let index = 0; index < pageCount; index += 1) {
328
+ doc.switchToPage(index)
329
+ doc
330
+ .font('body')
331
+ .fontSize(8)
332
+ .fillColor('#94A3B8')
333
+ .text(`第 ${index + 1} / ${pageCount} 页`, PDF_PAGE_MARGIN, doc.page.height - PDF_PAGE_MARGIN - 8, {
334
+ width: contentWidth,
335
+ align: 'right',
336
+ lineBreak: false,
337
+ })
338
+ }
339
+ doc.end()
340
+ return finished
341
+ }
@@ -29,7 +29,6 @@ export const MAX_HEADER_VALUE = 8000
29
29
 
30
30
  /** Headers a retest caller may never set (they are protocol-level). */
31
31
  const FORBIDDEN_HEADERS = new Set([
32
- 'host',
33
32
  'content-length',
34
33
  'connection',
35
34
  'transfer-encoding',
@@ -38,6 +37,14 @@ const FORBIDDEN_HEADERS = new Set([
38
37
  'proxy-connection',
39
38
  ])
40
39
 
40
+ /**
41
+ * Headers that are silently ignored instead of rejected: `Host` is derived
42
+ * from the already-validated request URL, so a raw request copied from a report
43
+ * (`Host: target`) must not fail validation. Letting callers override Host
44
+ * would allow vhost tricks outside the origin allowlist.
45
+ */
46
+ const IGNORED_HEADERS = new Set(['host'])
47
+
41
48
  const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
42
49
 
43
50
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
@@ -152,6 +159,7 @@ export function normalizeHeaders(headers) {
152
159
  if (FORBIDDEN_HEADERS.has(name.toLowerCase())) {
153
160
  throw badRequest(`header ${name} is not allowed`)
154
161
  }
162
+ if (IGNORED_HEADERS.has(name.toLowerCase())) continue
155
163
  if (/[\r\n]/.test(value)) throw badRequest(`header ${name} has an invalid value`)
156
164
  if (value.length > MAX_HEADER_VALUE) throw badRequest(`header ${name} is too long`)
157
165
  out[name] = value