@lievclouds/dsh-cortex 0.3.9 → 0.4.2

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/index.js CHANGED
@@ -46,9 +46,12 @@ export function apply(ctx, config) {
46
46
  'When the user asks for ANY authorized penetration-testing work (渗透测试 / SRC / 漏洞挖掘 / 安全评估 / 资产与接口侦察 / 内网横向 / 漏洞复测), use the Cortex tools proactively instead of relying on memory:',
47
47
  '- Reuse memory first: `cortex_list_snapshots` + `cortex_get_snapshot` for task progress, `cortex_list_vulns` + `cortex_get_asset` for existing findings/assets; `cortex_list_assets` before repeating recon.',
48
48
  '- Persist confirmed findings: dedupe with `cortex_list_vulns`, then `cortex_save_vuln` (reproduction_steps in Markdown).',
49
- '- Persist recon context: `cortex_sync_asset_info` (mode=merge) for fingerprints, interfaces (raw HTTP), leaks and test parameters.',
49
+ '- Persist recon context: `cortex_sync_asset_info` (mode=merge) for fingerprints, interfaces (canonical fields path/method/params/request), leaked_infos (info_type/value/source/potential_impact) and test_parameters (name/endpoint/location/values/source); aliases are normalized but the canonical shape is preferred.',
50
50
  '- Status / retest: `cortex_update_vuln_status`, `cortex_append_retest` (append-only, never overwrite original evidence).',
51
- '- Summaries: `cortex_internal_overview`, `cortex_graph_summary`.',
51
+ '- Task progress (认知快照): at task milestones, before context compaction and at session end call `cortex_save_snapshot` (project, phase, targets, unfinished tasks, failed paths, tested interfaces, notes, next steps); pass session_id to update an existing snapshot.',
52
+ '- Project names: reuse the existing project string exactly (list the assets or the audit first). Variants such as `上海春芝堂 SRC` and `上海春芝堂_SRC` are merged defensively, but do not invent a new spelling.',
53
+ '- Data completeness is mandatory: before finishing a phase call `cortex_audit_completeness`, then fill every gap — every asset must carry interfaces (path/method/params/request), leaked_infos (info_type/value/source/potential_impact) and test_parameters (name/endpoint/location/values/source); every confirmed finding must be saved with Markdown reproduction_steps; internal hosts/services/footholds/credentials/pivots go through `cortex_sync_internal_scope`.',
54
+ '- Summaries / audit: `cortex_internal_overview`, `cortex_graph_summary`, `cortex_audit_completeness`.',
52
55
  'Do not skip these calls merely because no skill was loaded. If a tool call fails, report the failure; never claim a record was saved without a successful result.',
53
56
  'Examples: “列出 Cortex 里的高危漏洞”, “把刚才确认的漏洞写入 Cortex”, “把 127.0.0.1:8080 的接口同步到资产库”, “把 VULN-… 标记为已确认并追加复测结论”.',
54
57
  ].join('\n'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lievclouds/dsh-cortex",
3
- "version": "0.3.9",
3
+ "version": "0.4.2",
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",
@@ -112,7 +112,16 @@ 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) => post('/vulns/export', { ids }),
115
+ exportVulnerabilities: async (ids) => {
116
+ const response = await post('/vulns/export', { ids })
117
+ const files = Array.isArray(response?.files) ? response.files : []
118
+ const savedPaths = Array.isArray(response?.savedPaths) ? response.savedPaths : []
119
+ return {
120
+ cancelled: response?.cancelled === true || files.length === 0,
121
+ files: files.map((file) => (file && typeof file.filename === 'string' ? file.filename : String(file))),
122
+ savedPaths: savedPaths.filter((path) => typeof path === 'string'),
123
+ }
124
+ },
116
125
 
117
126
  // ---------------------------------------------------------------- assets
118
127
  listAssets: (filters) => get('/assets', filters as Record<string, unknown>),
@@ -1,6 +1,7 @@
1
1
  // Bundle entry for the ported Cortex UI. Built by esbuild into an IIFE global
2
2
  // (`__DSH_CORTEX_UI__`); the DSH wrapper client.js mounts it inside a Shadow DOM
3
3
  // so the original Tailwind stylesheet never touches the DSH shell.
4
+ import { Component, type ReactNode } from 'react'
4
5
  import { createRoot } from 'react-dom/client'
5
6
  import App from '../renderer/App'
6
7
  import { setPortalContainer } from './portal'
@@ -9,6 +10,80 @@ export interface CortexUiHandle {
9
10
  destroy(): void
10
11
  }
11
12
 
13
+ interface BoundaryState {
14
+ error: Error | null
15
+ }
16
+
17
+ /**
18
+ * Last line of defence: a render crash in any page used to unmount the whole
19
+ * React tree and leave a blank white panel. Show the error and a reload button
20
+ * instead so the failure is visible and recoverable.
21
+ */
22
+ class CortexErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> {
23
+ state: BoundaryState = { error: null }
24
+
25
+ static getDerivedStateFromError(error: Error): BoundaryState {
26
+ return { error }
27
+ }
28
+
29
+ componentDidCatch(error: Error): void {
30
+ console.error('[dsh-cortex] Cortex renderer crashed', error)
31
+ }
32
+
33
+ render(): ReactNode {
34
+ if (this.state.error === null) return this.props.children
35
+ return (
36
+ <div
37
+ style={{
38
+ display: 'flex',
39
+ flexDirection: 'column',
40
+ alignItems: 'center',
41
+ justifyContent: 'center',
42
+ gap: 12,
43
+ height: '100%',
44
+ padding: 24,
45
+ fontFamily: 'Inter, "Microsoft YaHei UI", sans-serif',
46
+ fontSize: 13,
47
+ color: '#334155',
48
+ background: '#f8fafc',
49
+ }}
50
+ >
51
+ <strong style={{ fontSize: 15 }}>Cortex 面板渲染失败</strong>
52
+ <pre
53
+ style={{
54
+ maxWidth: '90%',
55
+ maxHeight: '40vh',
56
+ overflow: 'auto',
57
+ whiteSpace: 'pre-wrap',
58
+ fontSize: 11.5,
59
+ padding: 10,
60
+ border: '1px solid #e2e8f0',
61
+ borderRadius: 8,
62
+ background: '#ffffff',
63
+ color: '#b91c1c',
64
+ }}
65
+ >
66
+ {String(this.state.error.message || this.state.error)}
67
+ </pre>
68
+ <button
69
+ type="button"
70
+ onClick={() => window.location.reload()}
71
+ style={{
72
+ padding: '6px 14px',
73
+ border: '1px solid #cbd5e1',
74
+ borderRadius: 6,
75
+ background: '#ffffff',
76
+ fontSize: 12,
77
+ cursor: 'pointer',
78
+ }}
79
+ >
80
+ 重新加载页面
81
+ </button>
82
+ </div>
83
+ )
84
+ }
85
+ }
86
+
12
87
  /**
13
88
  * Mount the original Cortex renderer into `host`.
14
89
  * `css` is the Tailwind build with fonts inlined as data URLs.
@@ -32,7 +107,11 @@ export function mount(host: HTMLElement, css: string): CortexUiHandle {
32
107
  document.documentElement.lang = document.documentElement.lang || 'zh-CN'
33
108
 
34
109
  const root = createRoot(rootElement)
35
- root.render(<App />)
110
+ root.render(
111
+ <CortexErrorBoundary>
112
+ <App />
113
+ </CortexErrorBoundary>,
114
+ )
36
115
 
37
116
  return {
38
117
  destroy() {
@@ -33,6 +33,8 @@ import { AssetInternalEntry, scopeForEntry } from '@renderer/components/internal
33
33
  import {
34
34
  INTERFACE_METHOD_STYLES,
35
35
  buildInterfaceRequest,
36
+ normalizeDisplayInterfaces,
37
+ normalizeDisplayTestParameters,
36
38
  techStackTags
37
39
  } from '@renderer/lib/assets'
38
40
  import { apiUnavailableMessage, getApi, toErrorMessage } from '@renderer/lib/api'
@@ -353,9 +355,9 @@ export function AssetDetail({
353
355
  }, [assetProjectName, dataVersion, retryKey, internalRetryKey, t])
354
356
 
355
357
  const interfaces = asset
356
- ? (asset.interfaces ?? normalizeAssetInterfaces([], asset.interface_dir))
358
+ ? normalizeDisplayInterfaces(asset.interfaces ?? normalizeAssetInterfaces([], asset.interface_dir))
357
359
  : []
358
- const testParameters = asset?.test_parameters ?? []
360
+ const testParameters = asset ? normalizeDisplayTestParameters(asset.test_parameters) : []
359
361
  const tags = asset ? techStackTags(asset.tech_stack) : []
360
362
  const internalScope =
361
363
  asset === null
@@ -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 the hairline bottom border; the last row drops it. */
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.5 align-middle', last && 'border-b-0', extra)
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
- /** Technology fingerprints render as one complete chip per technology. */
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 visibleTags = tags.slice(0, 4)
40
- const hiddenCount = tags.length - visibleTags.length
41
-
42
+ const label = tags.join(' · ')
42
43
  return (
43
- <span className="flex flex-wrap items-center gap-1">
44
- {visibleTags.map((tag) => (
45
- <span
46
- key={tag}
47
- title={tag}
48
- className="inline-block max-w-full break-words rounded border border-border bg-sidebar px-1.5 py-0.5 font-mono text-[11px] leading-relaxed text-foreground/75"
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-3.5">
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-3.5">
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-3.5">
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-3.5">
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-3.5">
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-3.5">
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-3.5">
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-3.5">
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" />
@@ -67,7 +67,9 @@ function TestParameterCard({
67
67
  }) {
68
68
  const { t, locationLabel } = useI18n()
69
69
  const { copied, copy } = useCopy()
70
- const valuesText = testParameterValuesText(parameter.values)
70
+ const values = parameter.values ?? []
71
+ const relatedVulnIds = parameter.related_vuln_ids ?? []
72
+ const valuesText = testParameterValuesText(values)
71
73
 
72
74
  return (
73
75
  <article
@@ -120,11 +122,11 @@ function TestParameterCard({
120
122
  <div className="min-w-0">
121
123
  <p className="text-[11px] font-medium text-foreground/70">
122
124
  {t('asset.parameter.values')}
123
- {parameter.values.length > 0 ? ` · ${parameter.values.length}` : ''}
125
+ {values.length > 0 ? ` · ${values.length}` : ''}
124
126
  </p>
125
- {parameter.values.length > 0 ? (
127
+ {values.length > 0 ? (
126
128
  <div className="mt-1 flex flex-wrap gap-1">
127
- {parameter.values.map((value, index) => (
129
+ {values.map((value, index) => (
128
130
  <span
129
131
  key={`${value}-${index}`}
130
132
  data-testid="asset-test-parameter-value"
@@ -153,13 +155,13 @@ function TestParameterCard({
153
155
  />
154
156
  ) : null}
155
157
 
156
- {parameter.related_vuln_ids.length > 0 ? (
158
+ {relatedVulnIds.length > 0 ? (
157
159
  <div className="min-w-0">
158
160
  <p className="text-[11px] font-medium text-foreground/70">
159
161
  {t('asset.parameter.relatedVulns')}
160
162
  </p>
161
163
  <div className="mt-1 flex flex-wrap gap-1.5">
162
- {parameter.related_vuln_ids.map((vulnId) => (
164
+ {relatedVulnIds.map((vulnId) => (
163
165
  <Button
164
166
  key={vulnId}
165
167
  variant="outline"
@@ -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
@@ -262,7 +262,13 @@ export function VulnDetail({
262
262
  setExporting(true)
263
263
  try {
264
264
  const result = await api.exportVulnerabilities([detail.id])
265
- if (!result.cancelled) toast.success(t('vuln.toast.exported'))
265
+ if (!result.cancelled) {
266
+ const savedPath = result.savedPaths?.[0]
267
+ toast.success(
268
+ t('vuln.toast.exported'),
269
+ savedPath ? { description: t('vuln.toast.exportedPath', { path: savedPath }) } : undefined
270
+ )
271
+ }
266
272
  } catch (cause) {
267
273
  toast.error(t('vuln.toast.exportFailed', { error: toErrorMessage(cause, t) }))
268
274
  } finally {
@@ -91,6 +91,7 @@ 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}',
94
95
  'vuln.toast.exportFailed': '导出失败:{error}',
95
96
  'vuln.toast.deleted': '已删除 {id}',
96
97
  'vuln.toast.bulkDeleted': '已删除 {count} 条漏洞',
@@ -201,6 +202,7 @@ export const vulnMessages: MessageBundle = {
201
202
  'vuln.copy.codeBlockDone': 'Code block copied',
202
203
  'vuln.toast.exportedMany': 'Exported {count} Markdown reports',
203
204
  'vuln.toast.exported': 'Markdown report exported',
205
+ 'vuln.toast.exportedPath': 'Saved to: {path}',
204
206
  'vuln.toast.exportFailed': 'Export failed: {error}',
205
207
  'vuln.toast.deleted': 'Deleted {id}',
206
208
  'vuln.toast.bulkDeleted': 'Deleted {count} vulnerabilities',
@@ -192,3 +192,121 @@ export function testParameterValuesText(values: readonly string[]): string {
192
192
  .filter((value) => value.length > 0)
193
193
  .join('\n')
194
194
  }
195
+
196
+ /* ------------------------------------------------------------------------- */
197
+ /* Defensive normalization for agent-written asset JSON */
198
+ /* ------------------------------------------------------------------------- */
199
+
200
+ /** Derive the query string / body from a captured raw HTTP request. */
201
+ export function extractDisplayParamsFromRaw(raw: string | undefined): string {
202
+ const source = (raw ?? '').replace(/\r\n/g, '\n').trim()
203
+ if (source.length === 0) return ''
204
+ const lines = source.split('\n')
205
+ const requestLine = lines[0] ?? ''
206
+ const queryIndex = requestLine.indexOf('?')
207
+ const query = queryIndex >= 0 ? requestLine.slice(queryIndex).trim() : ''
208
+ const separator = source.indexOf('\n\n')
209
+ const body = separator >= 0 ? source.slice(separator + 2).trim() : ''
210
+ return [query, body].filter((part) => part.length > 0).join('\n')
211
+ }
212
+
213
+ /**
214
+ * The Host normalizes asset rows before serving them, but cached responses from
215
+ * older plugin builds (or an MCP-era database) may still contain aliases such as
216
+ * `raw` / `param` / `where` / `example`. Normalizing again in the renderer keeps
217
+ * the drawer and the memory graph crash-free instead of blanking the panel.
218
+ */
219
+ export function normalizeDisplayInterfaces(raw: unknown): AssetInterface[] {
220
+ const list = Array.isArray(raw) ? raw : []
221
+ const result: AssetInterface[] = []
222
+ for (const value of list) {
223
+ if (typeof value === 'string') {
224
+ if (value.trim().length > 0) result.push({ path: value.trim() })
225
+ continue
226
+ }
227
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
228
+ const item = value as Record<string, unknown>
229
+ const path = [item.path, item.url, item.route, item.endpoint]
230
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
231
+ .find((entry) => entry.length > 0)
232
+ if (!path) continue
233
+ const entry: AssetInterface = { path }
234
+ const method = typeof item.method === 'string' ? item.method.trim().toUpperCase() : ''
235
+ if ((INTERFACE_METHOD_STYLES as Record<string, string>)[method]) {
236
+ entry.method = method as AssetInterface['method']
237
+ }
238
+ const request = [item.request, item.raw, item.http]
239
+ .map((candidate) => (typeof candidate === 'string' ? candidate : ''))
240
+ .find((candidate) => candidate.trim().length > 0)
241
+ const params =
242
+ [item.params, item.parameters, item.query, item.data]
243
+ .map((candidate) => (typeof candidate === 'string' ? candidate.trim() : ''))
244
+ .find((candidate) => candidate.length > 0) || extractDisplayParamsFromRaw(request)
245
+ if (params) entry.params = params
246
+ if (request) entry.request = request
247
+ result.push(entry)
248
+ }
249
+ return result
250
+ }
251
+
252
+ /** Canonical test-parameter candidates from aliased agent records. */
253
+ export function normalizeDisplayTestParameters(raw: unknown): TestParameterCandidate[] {
254
+ const list = Array.isArray(raw) ? raw : []
255
+ const result: TestParameterCandidate[] = []
256
+ const seen = new Set<string>()
257
+ for (const value of list) {
258
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
259
+ const item = value as Record<string, unknown>
260
+ const pickText = (names: string[]): string => {
261
+ for (const name of names) {
262
+ const candidate = item[name]
263
+ if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
264
+ }
265
+ return ''
266
+ }
267
+ const name = pickText(['name', 'param', 'key'])
268
+ const endpoint = pickText(['endpoint', 'interface', 'api', 'path'])
269
+ const rawValues = Array.isArray(item.values) ? item.values : []
270
+ const values = (rawValues.length > 0 ? rawValues : [pickText(['example', 'value', 'sample'])])
271
+ .filter((entry): entry is string | number => typeof entry === 'string' || typeof entry === 'number')
272
+ .map(String)
273
+ .slice(0, 50)
274
+ if (name.length === 0 && endpoint.length === 0 && values.length === 0) continue
275
+ const locationRaw = pickText(['location', 'where', 'in']).toLowerCase()
276
+ const locationAliases: Record<string, string> = {
277
+ json: 'body',
278
+ form: 'body',
279
+ formdata: 'body',
280
+ post: 'body',
281
+ querystring: 'query',
282
+ url: 'query',
283
+ headers: 'header',
284
+ cookies: 'cookie',
285
+ }
286
+ const mappedLocation = locationAliases[locationRaw] || locationRaw
287
+ const location = (
288
+ ['path', 'query', 'body', 'header', 'cookie'].includes(mappedLocation) ? mappedLocation : 'query'
289
+ ) as TestParameterCandidate['location']
290
+ const source = pickText(['source', 'file', 'interface', 'endpoint']) || endpoint || '(unknown)'
291
+ const key = `${endpoint}\u0000${name}\u0000${location}`
292
+ if (seen.has(key)) continue
293
+ seen.add(key)
294
+ const entry: TestParameterCandidate = {
295
+ name: name || 'value',
296
+ endpoint: endpoint || source || '(unknown)',
297
+ location,
298
+ values,
299
+ source,
300
+ tested: item.tested === true,
301
+ related_vuln_ids: Array.isArray(item.related_vuln_ids)
302
+ ? item.related_vuln_ids.filter((entry): entry is string => typeof entry === 'string')
303
+ : [],
304
+ }
305
+ const pattern = pickText(['pattern'])
306
+ if (pattern) entry.pattern = pattern
307
+ const notes = pickText(['notes', 'note'])
308
+ if (notes) entry.notes = notes
309
+ result.push(entry)
310
+ }
311
+ return result
312
+ }
@@ -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
@@ -313,11 +313,15 @@ export function DashboardPage({
313
313
  setExporting(true)
314
314
  try {
315
315
  const result = await api.exportVulnerabilities(ids)
316
+ const savedPath = result.savedPaths?.[0]
316
317
  if (!result.cancelled) {
317
- toast.success(
318
+ const message =
318
319
  result.files.length > 1
319
320
  ? t('vuln.toast.exportedMany', { count: result.files.length })
320
321
  : t('vuln.toast.exported')
322
+ toast.success(
323
+ message,
324
+ savedPath ? { description: t('vuln.toast.exportedPath', { path: savedPath }) } : undefined
321
325
  )
322
326
  }
323
327
  } catch (cause) {
@@ -1020,8 +1020,10 @@ export interface DeleteVulnsResult {
1020
1020
 
1021
1021
  export interface ExportReportsResult {
1022
1022
  cancelled: boolean
1023
- /** Absolute paths of the written Markdown files. */
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
  /**
@@ -21,6 +21,12 @@ import {
21
21
  sendJson,
22
22
  } from '../http.js'
23
23
  import { jsonArray, likePattern, text, vulnListItem } from './vulns.js'
24
+ import {
25
+ normalizeInterfaces,
26
+ normalizeLeaks,
27
+ normalizeProjectName,
28
+ normalizeTestParameters,
29
+ } from './normalize-asset.js'
24
30
 
25
31
  const ASSET_STATUSES = ['Alive', 'Dead', 'WAF_Blocked']
26
32
  const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
@@ -51,71 +57,22 @@ function bool01(value) {
51
57
  }
52
58
 
53
59
  /**
54
- * Normalize stored `interfaces` to the shared `AssetInterface[]` shape.
55
- * @param {unknown} raw
60
+ * Normalize stored asset JSON to the canonical shared shapes. Agent-written
61
+ * records may use aliases (raw/request, type/file, param/where/example); the
62
+ * shared normalizers keep the drawer and the memory graph crash-free.
56
63
  */
57
64
  function interfaceList(raw) {
58
- const out = []
59
- for (const item of jsonArray(raw)) {
60
- if (typeof item === 'string') {
61
- if (item.trim().length > 0) out.push({ path: item.trim() })
62
- continue
63
- }
64
- if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
65
- const path = text(item.path).trim()
66
- if (path.length === 0) continue
67
- /** @type {Record<string, any>} */
68
- const entry = { path }
69
- if (typeof item.method === 'string' && HTTP_METHODS.includes(item.method)) entry.method = item.method
70
- if (typeof item.params === 'string' && item.params.length > 0) entry.params = item.params
71
- if (typeof item.request === 'string' && item.request.length > 0) entry.request = item.request
72
- out.push(entry)
73
- }
74
- return out
65
+ return normalizeInterfaces(raw)
75
66
  }
76
67
 
77
68
  /** @param {unknown} raw */
78
69
  function leakedInfoList(raw) {
79
- const out = []
80
- for (const item of jsonArray(raw)) {
81
- if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
82
- const infoType = text(item.info_type ?? item.infoType)
83
- out.push({
84
- info_type: LEAKED_INFO_TYPES.includes(infoType) ? infoType : 'Other',
85
- value: text(item.value),
86
- source: text(item.source),
87
- potential_impact: text(item.potential_impact ?? item.potentialImpact),
88
- })
89
- }
90
- return out
70
+ return normalizeLeaks(raw)
91
71
  }
92
72
 
93
73
  /** @param {unknown} raw */
94
74
  function testParameterList(raw) {
95
- const out = []
96
- for (const item of jsonArray(raw)) {
97
- if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
98
- const location = text(item.location)
99
- /** @type {Record<string, any>} */
100
- const entry = {
101
- name: text(item.name),
102
- endpoint: text(item.endpoint),
103
- location: TEST_PARAMETER_LOCATIONS.includes(location) ? location : 'query',
104
- values: jsonArray(item.values)
105
- .filter((value) => typeof value === 'string' || typeof value === 'number')
106
- .map((value) => String(value)),
107
- source: text(item.source),
108
- tested: item.tested === true,
109
- }
110
- if (typeof item.pattern === 'string' && item.pattern.length > 0) entry.pattern = item.pattern
111
- if (typeof item.notes === 'string' && item.notes.length > 0) entry.notes = item.notes
112
- const related = jsonArray(item.related_vuln_ids ?? item.relatedVulnIds).filter(
113
- (value) => typeof value === 'string',
114
- )
115
- if (related.length > 0) entry.related_vuln_ids = related
116
- out.push(entry)
117
- }
118
- return out
75
+ return normalizeTestParameters(raw)
119
76
  }
120
77
 
121
78
  /**
@@ -153,7 +110,7 @@ function assetListItem(row) {
153
110
  return {
154
111
  host_port: text(source.host_port),
155
112
  host: text(source.host),
156
- project_name: text(source.project_name),
113
+ project_name: normalizeProjectName(text(source.project_name)),
157
114
  tech_stack: text(source.tech_stack),
158
115
  auth_required: bool01(source.auth_required),
159
116
  status: ASSET_STATUSES.includes(text(source.status)) ? text(source.status) : 'Alive',
@@ -173,7 +130,7 @@ function assetContext(row) {
173
130
  return {
174
131
  host_port: text(source.host_port),
175
132
  host: text(source.host),
176
- project_name: text(source.project_name),
133
+ project_name: normalizeProjectName(text(source.project_name)),
177
134
  tech_stack: text(source.tech_stack),
178
135
  auth_required: bool01(source.auth_required),
179
136
  status: ASSET_STATUSES.includes(text(source.status)) ? text(source.status) : 'Alive',