@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/client.js +312 -307
- package/index.js +5 -2
- package/package.json +1 -1
- package/src/cortex-ui/dsh/api.ts +10 -1
- package/src/cortex-ui/dsh/entry.tsx +80 -1
- package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +4 -2
- package/src/cortex-ui/renderer/components/asset/AssetTable.tsx +21 -27
- package/src/cortex-ui/renderer/components/asset/TestParameterList.tsx +8 -6
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +3 -2
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +7 -1
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +2 -0
- package/src/cortex-ui/renderer/lib/assets.ts +118 -0
- package/src/cortex-ui/renderer/lib/retestRequest.ts +17 -0
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +5 -1
- package/src/cortex-ui/shared/types.ts +3 -1
- package/src/cortex-ui/shared/web.ts +2 -0
- package/src/host/cortex/assets.js +14 -57
- package/src/host/cortex/data-tools.js +393 -0
- package/src/host/cortex/graph-adapter.js +26 -22
- package/src/host/cortex/normalize-asset.js +208 -0
- package/src/host/cortex/tools.js +340 -22
- package/src/host/cortex/vulns.js +32 -2
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 (
|
|
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
|
-
'-
|
|
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
|
+
"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",
|
package/src/cortex-ui/dsh/api.ts
CHANGED
|
@@ -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) =>
|
|
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(
|
|
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
|
|
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
|
|
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" />
|
|
@@ -67,7 +67,9 @@ function TestParameterCard({
|
|
|
67
67
|
}) {
|
|
68
68
|
const { t, locationLabel } = useI18n()
|
|
69
69
|
const { copied, copy } = useCopy()
|
|
70
|
-
const
|
|
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
|
-
{
|
|
125
|
+
{values.length > 0 ? ` · ${values.length}` : ''}
|
|
124
126
|
</p>
|
|
125
|
-
{
|
|
127
|
+
{values.length > 0 ? (
|
|
126
128
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
127
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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)
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
55
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|