@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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defensive normalizers for agent-written asset JSON.
|
|
3
|
+
*
|
|
4
|
+
* MCP-era skills asked agents for one canonical shape; with the plugin tools
|
|
5
|
+
* the model sometimes writes aliases (`raw` instead of `request`, `type`/`file`
|
|
6
|
+
* instead of `info_type`/`source`, `param`/`where`/`example` instead of
|
|
7
|
+
* `name`/`location`/`values`). The original renderer and the memory-graph
|
|
8
|
+
* builder assume the canonical shape, so malformed-but-useful records used to
|
|
9
|
+
* crash the asset drawer / the nebula graph. Normalize on read and on write.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
|
|
13
|
+
export const LEAKED_INFO_TYPES = [
|
|
14
|
+
'Credential',
|
|
15
|
+
'API_Key',
|
|
16
|
+
'Token',
|
|
17
|
+
'PII',
|
|
18
|
+
'Internal_IP',
|
|
19
|
+
'Other',
|
|
20
|
+
]
|
|
21
|
+
export const TEST_PARAMETER_LOCATIONS = ['path', 'query', 'body', 'header', 'cookie']
|
|
22
|
+
|
|
23
|
+
const text = (value) => (typeof value === 'string' ? value.trim() : '')
|
|
24
|
+
const pick = (...values) => {
|
|
25
|
+
for (const value of values) {
|
|
26
|
+
const candidate = text(value)
|
|
27
|
+
if (candidate.length > 0) return candidate
|
|
28
|
+
}
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Canonical project label: agents sometimes write `上海春芝堂 SRC` and
|
|
34
|
+
* `上海春芝堂_SRC`; underscores and repeated spaces are display noise. The
|
|
35
|
+
* graph groups by this value, so normalizing it merges variant roots.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeProjectName(value) {
|
|
38
|
+
const label = (typeof value === 'string' ? value : '')
|
|
39
|
+
.replace(/\s+/g, ' ')
|
|
40
|
+
.trim()
|
|
41
|
+
// CJK projects often get written as `上海春芝堂_SRC` vs `上海春芝堂 SRC`;
|
|
42
|
+
// merge those variants without renaming pure-ASCII projects like `app_web`.
|
|
43
|
+
return /[\u4e00-\u9fff]/.test(label) ? label.replace(/_/g, ' ').replace(/\s+/g, ' ').trim() : label
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseJsonArray(value) {
|
|
47
|
+
if (Array.isArray(value)) return value
|
|
48
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(value)
|
|
51
|
+
return Array.isArray(parsed) ? parsed : []
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pull the query string / body out of a captured raw HTTP request. */
|
|
60
|
+
export function extractParamsFromRaw(raw) {
|
|
61
|
+
const source = text(raw).replace(/\r\n/g, '\n')
|
|
62
|
+
if (source.length === 0) return ''
|
|
63
|
+
const lines = source.split('\n')
|
|
64
|
+
const requestLine = lines[0] || ''
|
|
65
|
+
const queryIndex = requestLine.indexOf('?')
|
|
66
|
+
const query = queryIndex >= 0 ? requestLine.slice(queryIndex).trim() : ''
|
|
67
|
+
const separator = source.indexOf('\n\n')
|
|
68
|
+
const body = separator >= 0 ? source.slice(separator + 2).trim() : ''
|
|
69
|
+
return [query, body].filter((part) => part.length > 0).join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @returns {{path:string, method?:string, params?:string, request?:string}|null} */
|
|
73
|
+
export function normalizeInterface(raw) {
|
|
74
|
+
if (typeof raw === 'string') {
|
|
75
|
+
const path = raw.trim()
|
|
76
|
+
return path.length > 0 ? { path } : null
|
|
77
|
+
}
|
|
78
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
79
|
+
const path = pick(raw.path, raw.url, raw.route, raw.endpoint)
|
|
80
|
+
if (path.length === 0) return null
|
|
81
|
+
const entry = { path }
|
|
82
|
+
const method = pick(raw.method).toUpperCase()
|
|
83
|
+
if (HTTP_METHODS.includes(method)) entry.method = method
|
|
84
|
+
const request = pick(raw.request, raw.raw, raw.http)
|
|
85
|
+
const params = pick(raw.params, raw.parameters, raw.query, raw.data) || extractParamsFromRaw(request)
|
|
86
|
+
if (params.length > 0) entry.params = params
|
|
87
|
+
if (request.length > 0) entry.request = request
|
|
88
|
+
return entry
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @returns {Array<{path:string, method?:string, params?:string, request?:string}>} */
|
|
92
|
+
export function normalizeInterfaces(raw, interfaceDir) {
|
|
93
|
+
const result = []
|
|
94
|
+
const seen = new Set()
|
|
95
|
+
const push = (entry) => {
|
|
96
|
+
if (!entry) return
|
|
97
|
+
const key = `${entry.method || ''}\u0000${entry.path}`
|
|
98
|
+
if (seen.has(key)) return
|
|
99
|
+
seen.add(key)
|
|
100
|
+
result.push(entry)
|
|
101
|
+
}
|
|
102
|
+
for (const item of parseJsonArray(raw)) push(normalizeInterface(item))
|
|
103
|
+
for (const item of parseJsonArray(interfaceDir)) {
|
|
104
|
+
const path = text(item)
|
|
105
|
+
if (path.length > 0) push(normalizeInterface({ path }))
|
|
106
|
+
}
|
|
107
|
+
return result
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @returns {{info_type:string, value:string, source:string, potential_impact:string}} */
|
|
111
|
+
export function normalizeLeak(raw) {
|
|
112
|
+
const item = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
|
|
113
|
+
const rawType = pick(item.info_type, item.infoType, item.type, item.kind, item.category)
|
|
114
|
+
const infoType = LEAKED_INFO_TYPES.includes(rawType)
|
|
115
|
+
? rawType
|
|
116
|
+
: rawType === 'public_config' || rawType === 'config'
|
|
117
|
+
? 'Other'
|
|
118
|
+
: 'Other'
|
|
119
|
+
return {
|
|
120
|
+
info_type: infoType,
|
|
121
|
+
value: pick(item.value, item.content, item.data, item.secret, item.example),
|
|
122
|
+
source: pick(item.source, item.file, item.where, item.url, item.path, item.endpoint),
|
|
123
|
+
potential_impact: pick(item.potential_impact, item.potentialImpact, item.impact, item.note),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @returns {Array<ReturnType<typeof normalizeLeak>>} */
|
|
128
|
+
export function normalizeLeaks(raw) {
|
|
129
|
+
const result = []
|
|
130
|
+
for (const item of parseJsonArray(raw)) {
|
|
131
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
|
|
132
|
+
const leak = normalizeLeak(item)
|
|
133
|
+
if (leak.value.length === 0 && leak.source.length === 0) continue
|
|
134
|
+
result.push(leak)
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @returns {object|null} canonical TestParameterCandidate */
|
|
140
|
+
export function normalizeTestParameter(raw) {
|
|
141
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
142
|
+
const name = pick(raw.name, raw.param, raw.key)
|
|
143
|
+
const endpoint = pick(raw.endpoint, raw.interface, raw.api, raw.path)
|
|
144
|
+
const values = Array.isArray(raw.values)
|
|
145
|
+
? raw.values.filter((value) => typeof value === 'string' || typeof value === 'number').map(String)
|
|
146
|
+
: [pick(raw.example, raw.example_value, raw.value, raw.sample)].filter((value) => value.length > 0)
|
|
147
|
+
const locationRaw = pick(raw.location, raw.where, raw.in).toLowerCase()
|
|
148
|
+
const locationAliases = {
|
|
149
|
+
json: 'body',
|
|
150
|
+
form: 'body',
|
|
151
|
+
formdata: 'body',
|
|
152
|
+
post: 'body',
|
|
153
|
+
query: 'query',
|
|
154
|
+
querystring: 'query',
|
|
155
|
+
url: 'query',
|
|
156
|
+
header: 'header',
|
|
157
|
+
headers: 'header',
|
|
158
|
+
cookie: 'cookie',
|
|
159
|
+
path: 'path',
|
|
160
|
+
}
|
|
161
|
+
const mappedLocation = locationAliases[locationRaw] || locationRaw
|
|
162
|
+
const location = TEST_PARAMETER_LOCATIONS.includes(mappedLocation) ? mappedLocation : 'query'
|
|
163
|
+
const source = pick(raw.source, raw.file, raw.interface, raw.endpoint)
|
|
164
|
+
if (name.length === 0 && endpoint.length === 0 && values.length === 0) return null
|
|
165
|
+
const entry = {
|
|
166
|
+
name: name || 'value',
|
|
167
|
+
endpoint: endpoint || source || '(unknown)',
|
|
168
|
+
location,
|
|
169
|
+
values: values.slice(0, 50),
|
|
170
|
+
source: source || endpoint || '(unknown)',
|
|
171
|
+
tested: raw.tested === true,
|
|
172
|
+
related_vuln_ids: Array.isArray(raw.related_vuln_ids)
|
|
173
|
+
? raw.related_vuln_ids.filter((value) => typeof value === 'string' && value.trim().length > 0)
|
|
174
|
+
: [],
|
|
175
|
+
}
|
|
176
|
+
const pattern = pick(raw.pattern)
|
|
177
|
+
if (pattern.length > 0) entry.pattern = pattern
|
|
178
|
+
const notes = pick(raw.notes, raw.note)
|
|
179
|
+
if (notes.length > 0) entry.notes = notes
|
|
180
|
+
return entry
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @returns {Array<ReturnType<typeof normalizeTestParameter>>} */
|
|
184
|
+
export function normalizeTestParameters(raw) {
|
|
185
|
+
const result = []
|
|
186
|
+
const seen = new Set()
|
|
187
|
+
for (const item of parseJsonArray(raw)) {
|
|
188
|
+
const entry = normalizeTestParameter(item)
|
|
189
|
+
if (!entry) continue
|
|
190
|
+
const key = `${entry.endpoint}\u0000${entry.name}\u0000${entry.location}`
|
|
191
|
+
if (seen.has(key)) continue
|
|
192
|
+
seen.add(key)
|
|
193
|
+
result.push(entry)
|
|
194
|
+
}
|
|
195
|
+
return result
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Normalize one raw asset row for API / graph consumers. */
|
|
199
|
+
export function normalizeAssetRow(row) {
|
|
200
|
+
if (!row || typeof row !== 'object') return row
|
|
201
|
+
return {
|
|
202
|
+
...row,
|
|
203
|
+
interfaces: normalizeInterfaces(row.interfaces, row.interface_dir),
|
|
204
|
+
leaked_infos: normalizeLeaks(row.leaked_infos),
|
|
205
|
+
test_parameters: normalizeTestParameters(row.test_parameters),
|
|
206
|
+
related_vuln_ids: parseJsonArray(row.related_vuln_ids).filter((value) => typeof value === 'string'),
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/host/cortex/tools.js
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
* honours the same schema-v5 guard and read-only mode as the HTTP API.
|
|
8
8
|
*/
|
|
9
9
|
import { withBusyRetry } from '../db.js'
|
|
10
|
+
import {
|
|
11
|
+
normalizeInterfaces,
|
|
12
|
+
normalizeLeaks,
|
|
13
|
+
normalizeProjectName,
|
|
14
|
+
normalizeTestParameters,
|
|
15
|
+
} from './normalize-asset.js'
|
|
16
|
+
import { auditCompleteness, syncInternalScope } from './data-tools.js'
|
|
10
17
|
|
|
11
18
|
const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info']
|
|
12
19
|
const STATUSES = ['Confirmed', 'Inconclusive', 'Retest_Passed']
|
|
@@ -394,7 +401,7 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
394
401
|
|
|
395
402
|
const syncAsset = defineTool(
|
|
396
403
|
'cortex_sync_asset_info',
|
|
397
|
-
'Insert or update local Cortex asset context
|
|
404
|
+
'Insert or update local Cortex asset context. mode=merge (default) appends canonicalized array entries and only overwrites provided scalars; mode=replace replaces the whole asset snapshot. Canonical fields: interfaces[] = {path, method?, params?, request?} where request is a complete raw HTTP request; leaked_infos[] = {info_type, value, source, potential_impact}; test_parameters[] = {name, endpoint, location (path|query|body|header|cookie), values[], pattern?, source, tested?, notes?, related_vuln_ids?}. Common aliases are normalized (raw->request, type->info_type, file->source, param->name, where->location, example->values[0]).',
|
|
398
405
|
{
|
|
399
406
|
host_port: { type: 'string', description: 'Full URL/authority, e.g. http://127.0.0.1:8080/' },
|
|
400
407
|
project_name: { type: 'string' },
|
|
@@ -402,9 +409,53 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
402
409
|
tech_stack: { type: 'string' },
|
|
403
410
|
auth_required: { type: 'boolean' },
|
|
404
411
|
status: { type: 'string', enum: ['Alive', 'Dead', 'WAF_Blocked'] },
|
|
405
|
-
interfaces: {
|
|
406
|
-
|
|
407
|
-
|
|
412
|
+
interfaces: {
|
|
413
|
+
type: 'array',
|
|
414
|
+
description: 'Structured API interfaces; raw static files are not interfaces. Example: {path: "/api/login", method: "POST", params: "{\\"user\\":\\"a\\"}", request: "POST /api/login HTTP/1.1\nHost: host\n\n{...}"}',
|
|
415
|
+
items: {
|
|
416
|
+
type: 'object',
|
|
417
|
+
properties: {
|
|
418
|
+
path: { type: 'string', description: 'API path (required)' },
|
|
419
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] },
|
|
420
|
+
params: { type: 'string', description: 'Query string or body fields' },
|
|
421
|
+
request: { type: 'string', description: 'Complete raw HTTP request (request line + headers + blank line + body)' },
|
|
422
|
+
},
|
|
423
|
+
required: ['path'],
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
leaked_infos: {
|
|
427
|
+
type: 'array',
|
|
428
|
+
description: 'Secrets/PII/internal IPs. Example: {info_type: "Token", value: "eyJ...", source: "/js/app.js", potential_impact: "可伪造会话"}',
|
|
429
|
+
items: {
|
|
430
|
+
type: 'object',
|
|
431
|
+
properties: {
|
|
432
|
+
info_type: { type: 'string', enum: ['Credential', 'API_Key', 'Token', 'PII', 'Internal_IP', 'Other'] },
|
|
433
|
+
value: { type: 'string' },
|
|
434
|
+
source: { type: 'string', description: 'Where it was found (URL/file/JS bundle)' },
|
|
435
|
+
potential_impact: { type: 'string' },
|
|
436
|
+
},
|
|
437
|
+
required: ['info_type', 'value', 'source', 'potential_impact'],
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
test_parameters: {
|
|
441
|
+
type: 'array',
|
|
442
|
+
description: 'Non-secret seeds for authz tests. Example: {name: "orderId", endpoint: "GET /api/orders/{id}", location: "path", values: ["1001"], source: "/js/app.js", pattern: "sequential numeric"}',
|
|
443
|
+
items: {
|
|
444
|
+
type: 'object',
|
|
445
|
+
properties: {
|
|
446
|
+
name: { type: 'string' },
|
|
447
|
+
endpoint: { type: 'string' },
|
|
448
|
+
location: { type: 'string', enum: ['path', 'query', 'body', 'header', 'cookie'] },
|
|
449
|
+
values: { type: 'array', items: { type: 'string' } },
|
|
450
|
+
pattern: { type: 'string' },
|
|
451
|
+
source: { type: 'string' },
|
|
452
|
+
tested: { type: 'boolean' },
|
|
453
|
+
notes: { type: 'string' },
|
|
454
|
+
related_vuln_ids: { type: 'array', items: { type: 'string' } },
|
|
455
|
+
},
|
|
456
|
+
required: ['name', 'endpoint', 'location', 'source'],
|
|
457
|
+
},
|
|
458
|
+
},
|
|
408
459
|
related_vuln_ids: { type: 'array', items: { type: 'string' } },
|
|
409
460
|
mode: { type: 'string', enum: ['merge', 'replace'] },
|
|
410
461
|
},
|
|
@@ -413,15 +464,14 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
413
464
|
assertWritable(holder)
|
|
414
465
|
const db = ensureDb(holder)
|
|
415
466
|
const hostPort = text(args.host_port).trim()
|
|
416
|
-
const project = text(args.project_name)
|
|
467
|
+
const project = normalizeProjectName(text(args.project_name))
|
|
417
468
|
if (!hostPort || !project) throw new Error('host_port and project_name are required')
|
|
418
469
|
const merge = args.mode !== 'replace'
|
|
419
470
|
const existing = db.prepare('SELECT * FROM assets WHERE host_port = ?').get(hostPort)
|
|
420
471
|
const now = new Date().toISOString()
|
|
421
|
-
const arrayField = (name) => {
|
|
422
|
-
const value =
|
|
472
|
+
const arrayField = (name, normalize) => {
|
|
473
|
+
const value = args[name] !== undefined && normalize ? normalize(args[name]) : undefined
|
|
423
474
|
if (merge && existing) return jsonColumn(uniqueJsonMerge(existing[name], value))
|
|
424
|
-
if (merge && !existing) return jsonColumn(value)
|
|
425
475
|
return jsonColumn(value)
|
|
426
476
|
}
|
|
427
477
|
const scalar = (name, fallback) =>
|
|
@@ -432,10 +482,12 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
432
482
|
tech_stack: args.tech_stack !== undefined ? text(args.tech_stack) : merge && existing ? existing.tech_stack : '',
|
|
433
483
|
auth_required: scalar('auth_required', false) ? 1 : 0,
|
|
434
484
|
status: scalar('status', 'Alive'),
|
|
435
|
-
interfaces: arrayField('interfaces'),
|
|
436
|
-
leaked_infos: arrayField('leaked_infos'),
|
|
437
|
-
test_parameters: arrayField('test_parameters'),
|
|
438
|
-
related_vuln_ids: arrayField('related_vuln_ids')
|
|
485
|
+
interfaces: arrayField('interfaces', normalizeInterfaces),
|
|
486
|
+
leaked_infos: arrayField('leaked_infos', normalizeLeaks),
|
|
487
|
+
test_parameters: arrayField('test_parameters', normalizeTestParameters),
|
|
488
|
+
related_vuln_ids: arrayField('related_vuln_ids', (value) =>
|
|
489
|
+
Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [],
|
|
490
|
+
),
|
|
439
491
|
last_seen: now,
|
|
440
492
|
}
|
|
441
493
|
runWrite(db, () => {
|
|
@@ -454,12 +506,157 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
454
506
|
}
|
|
455
507
|
})
|
|
456
508
|
holder.refreshFileStamp()
|
|
509
|
+
const counts = {
|
|
510
|
+
interfaces: jsonArray(values.interfaces).length,
|
|
511
|
+
leaks: jsonArray(values.leaked_infos).length,
|
|
512
|
+
parameters: jsonArray(values.test_parameters).length,
|
|
513
|
+
}
|
|
514
|
+
const warnings = []
|
|
515
|
+
for (const [name, key] of [
|
|
516
|
+
['interfaces', 'interfaces'],
|
|
517
|
+
['leaked_infos', 'leaks'],
|
|
518
|
+
['test_parameters', 'parameters'],
|
|
519
|
+
]) {
|
|
520
|
+
if (args[name] === undefined) continue
|
|
521
|
+
const sent = Array.isArray(args[name]) ? args[name].length : 0
|
|
522
|
+
if (sent > counts[key]) warnings.push(`${name}: ${sent - counts[key]} invalid entries dropped by normalization`)
|
|
523
|
+
}
|
|
524
|
+
if (counts.interfaces === 0) warnings.push('interfaces is empty — add {path, method, params?, request?} for each API')
|
|
525
|
+
if (counts.leaks === 0) warnings.push('leaked_infos is empty — record secrets/tokens/internal IPs with value+source+impact')
|
|
526
|
+
if (counts.parameters === 0) warnings.push('test_parameters is empty — record id/tenant/order seeds with name+endpoint+location+values+source')
|
|
457
527
|
return {
|
|
458
|
-
text:
|
|
528
|
+
text: [
|
|
529
|
+
`${LOG} ${existing ? (merge ? 'merged' : 'replaced') : 'created'} asset ${hostPort} (project ${project})`,
|
|
530
|
+
`stored: interfaces=${counts.interfaces}, leaked_infos=${counts.leaks}, test_parameters=${counts.parameters}`,
|
|
531
|
+
...warnings.map((warning) => `warning: ${warning}`),
|
|
532
|
+
].join('\n'),
|
|
459
533
|
}
|
|
460
534
|
},
|
|
461
535
|
)
|
|
462
536
|
|
|
537
|
+
const saveSnapshot = defineTool(
|
|
538
|
+
'cortex_save_snapshot',
|
|
539
|
+
'Create or update a local Cortex cognitive snapshot (认知快照) for the current task: project, phase, targets, unfinished tasks, failed paths, tested interfaces/notes/next steps and related vulns. Call it at task milestones, before context compaction and at session end. Omit session_id to create a new snapshot; pass session_id to update one; force_new=true creates another session for a project that already has one. Updates keep a previous_state rollback buffer.',
|
|
540
|
+
{
|
|
541
|
+
project_name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
542
|
+
current_phase: { type: 'string', maxLength: 200 },
|
|
543
|
+
target_hosts: { type: 'array', items: { type: 'string' } },
|
|
544
|
+
unfinished_tasks: { type: 'array', items: { type: 'string' } },
|
|
545
|
+
failed_paths: { type: 'array', items: { type: 'string' } },
|
|
546
|
+
tested_interfaces: { type: 'array', items: { type: 'object' }, description: 'Tested interface matrix entries (endpoint/result/...)' },
|
|
547
|
+
notes: { type: 'string', maxLength: 30000 },
|
|
548
|
+
related_vuln_ids: { type: 'array', items: { type: 'string' } },
|
|
549
|
+
next_steps: { type: 'string', maxLength: 10000 },
|
|
550
|
+
session_id: { type: 'string', maxLength: 100 },
|
|
551
|
+
parent_session_id: { type: 'string', maxLength: 100 },
|
|
552
|
+
force_new: { type: 'boolean' },
|
|
553
|
+
},
|
|
554
|
+
['project_name'],
|
|
555
|
+
async (args) => {
|
|
556
|
+
assertWritable(holder)
|
|
557
|
+
const db = ensureDb(holder)
|
|
558
|
+
const project = normalizeProjectName(text(args.project_name))
|
|
559
|
+
if (!project) throw new Error('project_name is required')
|
|
560
|
+
const stringList = (name) =>
|
|
561
|
+
args[name] === undefined
|
|
562
|
+
? undefined
|
|
563
|
+
: JSON.stringify(
|
|
564
|
+
(Array.isArray(args[name]) ? args[name] : [])
|
|
565
|
+
.filter((entry) => typeof entry === 'string')
|
|
566
|
+
.map((entry) => entry.trim())
|
|
567
|
+
.filter((entry) => entry.length > 0),
|
|
568
|
+
)
|
|
569
|
+
const objectList = (name) =>
|
|
570
|
+
args[name] === undefined
|
|
571
|
+
? undefined
|
|
572
|
+
: JSON.stringify((Array.isArray(args[name]) ? args[name] : []).filter((entry) => entry && typeof entry === 'object'))
|
|
573
|
+
const fields = {
|
|
574
|
+
project_name: project,
|
|
575
|
+
current_phase: args.current_phase !== undefined ? text(args.current_phase) : undefined,
|
|
576
|
+
target_hosts: stringList('target_hosts'),
|
|
577
|
+
unfinished_tasks: stringList('unfinished_tasks'),
|
|
578
|
+
failed_paths: stringList('failed_paths'),
|
|
579
|
+
tested_interfaces: objectList('tested_interfaces'),
|
|
580
|
+
notes: args.notes !== undefined ? text(args.notes) : undefined,
|
|
581
|
+
related_vuln_ids: stringList('related_vuln_ids'),
|
|
582
|
+
next_steps: args.next_steps !== undefined ? text(args.next_steps) : undefined,
|
|
583
|
+
}
|
|
584
|
+
const now = new Date().toISOString()
|
|
585
|
+
const sessionId = text(args.session_id).trim()
|
|
586
|
+
if (sessionId) {
|
|
587
|
+
const existing = db.prepare('SELECT * FROM snapshot_sessions WHERE session_id = ?').get(sessionId)
|
|
588
|
+
if (!existing) throw new Error(`snapshot ${sessionId} not found; omit session_id to create a new one`)
|
|
589
|
+
const previous = {
|
|
590
|
+
project_name: existing.project_name,
|
|
591
|
+
target_hosts: jsonArray(existing.target_hosts),
|
|
592
|
+
current_phase: existing.current_phase,
|
|
593
|
+
unfinished_tasks: jsonArray(existing.unfinished_tasks),
|
|
594
|
+
failed_paths: jsonArray(existing.failed_paths),
|
|
595
|
+
tested_interfaces: jsonArray(existing.tested_interfaces),
|
|
596
|
+
notes: existing.notes,
|
|
597
|
+
related_vuln_ids: jsonArray(existing.related_vuln_ids),
|
|
598
|
+
next_steps: existing.next_steps,
|
|
599
|
+
}
|
|
600
|
+
const sets = []
|
|
601
|
+
const values = []
|
|
602
|
+
for (const [column, value] of Object.entries(fields)) {
|
|
603
|
+
if (value === undefined) continue
|
|
604
|
+
sets.push(`${column} = ?`)
|
|
605
|
+
values.push(value)
|
|
606
|
+
}
|
|
607
|
+
sets.push('previous_state = ?', 'updated_at = ?')
|
|
608
|
+
values.push(JSON.stringify(previous), now, sessionId)
|
|
609
|
+
await runWrite(db, () =>
|
|
610
|
+
db.prepare(`UPDATE snapshot_sessions SET ${sets.join(', ')} WHERE session_id = ?`).run(...values),
|
|
611
|
+
)
|
|
612
|
+
holder.refreshFileStamp()
|
|
613
|
+
return { text: `${LOG} snapshot updated: ${sessionId} | ${project} | phase: ${fields.current_phase ?? existing.current_phase}` }
|
|
614
|
+
}
|
|
615
|
+
if (args.force_new !== true) {
|
|
616
|
+
const candidates = db
|
|
617
|
+
.prepare('SELECT session_id, current_phase, updated_at FROM snapshot_sessions WHERE project_name = ? AND archived_at IS NULL ORDER BY updated_at DESC LIMIT 5')
|
|
618
|
+
.all(project)
|
|
619
|
+
if (candidates.length > 0) {
|
|
620
|
+
return {
|
|
621
|
+
text: `${LOG} project ${project} already has snapshots: ${candidates
|
|
622
|
+
.map((row) => `${row.session_id} (${row.current_phase || '-'}, ${row.updated_at})`)
|
|
623
|
+
.join('; ')} — pass session_id to update one, or force_new=true to create a new session.`,
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const id = globalThis.crypto?.randomUUID?.() ?? `snap-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
628
|
+
const columns = [
|
|
629
|
+
'session_id', 'project_name', 'target_hosts', 'current_phase', 'unfinished_tasks', 'failed_paths',
|
|
630
|
+
'tested_interfaces', 'notes', 'related_vuln_ids', 'next_steps', 'previous_state',
|
|
631
|
+
'parent_session_id', 'archived_at', 'created_at', 'updated_at',
|
|
632
|
+
]
|
|
633
|
+
const values = [
|
|
634
|
+
id,
|
|
635
|
+
project,
|
|
636
|
+
fields.target_hosts ?? '[]',
|
|
637
|
+
fields.current_phase ?? '',
|
|
638
|
+
fields.unfinished_tasks ?? '[]',
|
|
639
|
+
fields.failed_paths ?? '[]',
|
|
640
|
+
fields.tested_interfaces ?? '[]',
|
|
641
|
+
fields.notes ?? '',
|
|
642
|
+
fields.related_vuln_ids ?? '[]',
|
|
643
|
+
fields.next_steps ?? '',
|
|
644
|
+
null,
|
|
645
|
+
text(args.parent_session_id) || null,
|
|
646
|
+
null,
|
|
647
|
+
now,
|
|
648
|
+
now,
|
|
649
|
+
]
|
|
650
|
+
await runWrite(db, () =>
|
|
651
|
+
db
|
|
652
|
+
.prepare(`INSERT INTO snapshot_sessions (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`)
|
|
653
|
+
.run(...values),
|
|
654
|
+
)
|
|
655
|
+
holder.refreshFileStamp()
|
|
656
|
+
return { text: `${LOG} snapshot saved: ${id} | ${project} | phase: ${fields.current_phase || '-'}` }
|
|
657
|
+
},
|
|
658
|
+
)
|
|
659
|
+
|
|
463
660
|
const listAssets = defineTool(
|
|
464
661
|
'cortex_list_assets',
|
|
465
662
|
'List local Cortex assets (host_port, project, tech stack, interface/leak/test-parameter counts). Optional search/status/project filters.',
|
|
@@ -493,7 +690,7 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
493
690
|
.all(...params)
|
|
494
691
|
const lines = rows.map(
|
|
495
692
|
(row) =>
|
|
496
|
-
`${row.host_port} | ${row.status} | ${row.project_name} | ifaces=${jsonArray(row.interfaces).length} leaks=${jsonArray(row.leaked_infos).length} params=${jsonArray(row.test_parameters).length} vulns=${jsonArray(row.related_vuln_ids).length} | ${row.tech_stack}`,
|
|
693
|
+
`${row.host_port} | ${row.status} | ${normalizeProjectName(row.project_name)} | ifaces=${jsonArray(row.interfaces).length} leaks=${jsonArray(row.leaked_infos).length} params=${jsonArray(row.test_parameters).length} vulns=${jsonArray(row.related_vuln_ids).length} | ${row.tech_stack}`,
|
|
497
694
|
)
|
|
498
695
|
return { text: [`${LOG} ${rows.length} assets`, ...lines].join('\n') }
|
|
499
696
|
},
|
|
@@ -508,22 +705,26 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
508
705
|
const db = ensureDb(holder)
|
|
509
706
|
const row = db.prepare('SELECT * FROM assets WHERE host_port = ?').get(text(args.hostPort).trim())
|
|
510
707
|
if (!row) return { text: `${LOG} asset not found: ${args.hostPort}` }
|
|
708
|
+
const interfaces = normalizeInterfaces(row.interfaces, row.interface_dir)
|
|
709
|
+
const leaks = normalizeLeaks(row.leaked_infos)
|
|
710
|
+
const testParameters = normalizeTestParameters(row.test_parameters)
|
|
711
|
+
const relatedIds = jsonArray(row.related_vuln_ids).filter((entry) => typeof entry === 'string')
|
|
511
712
|
const parts = [
|
|
512
713
|
`# ${row.host_port}`,
|
|
513
|
-
`host: ${row.host} · project: ${row.project_name}`,
|
|
714
|
+
`host: ${row.host} · project: ${normalizeProjectName(row.project_name)}`,
|
|
514
715
|
`status: ${row.status} · auth_required: ${row.auth_required ? 'true' : 'false'} · last_seen: ${row.last_seen}`,
|
|
515
716
|
`tech_stack: ${row.tech_stack}`,
|
|
516
717
|
'',
|
|
517
|
-
`## interfaces (${
|
|
518
|
-
JSON.stringify(
|
|
718
|
+
`## interfaces (${interfaces.length})`,
|
|
719
|
+
JSON.stringify(interfaces, null, 2),
|
|
519
720
|
'',
|
|
520
|
-
`## leaked_infos (${
|
|
521
|
-
JSON.stringify(
|
|
721
|
+
`## leaked_infos (${leaks.length})`,
|
|
722
|
+
JSON.stringify(leaks, null, 2),
|
|
522
723
|
'',
|
|
523
|
-
`## test_parameters (${
|
|
524
|
-
JSON.stringify(
|
|
724
|
+
`## test_parameters (${testParameters.length})`,
|
|
725
|
+
JSON.stringify(testParameters, null, 2),
|
|
525
726
|
'',
|
|
526
|
-
`## related_vuln_ids\n${
|
|
727
|
+
`## related_vuln_ids\n${relatedIds.join(', ')}`,
|
|
527
728
|
]
|
|
528
729
|
return { text: parts.join('\n') }
|
|
529
730
|
},
|
|
@@ -650,6 +851,120 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
650
851
|
},
|
|
651
852
|
)
|
|
652
853
|
|
|
854
|
+
const auditTool = defineTool(
|
|
855
|
+
'cortex_audit_completeness',
|
|
856
|
+
'Audit local Cortex data completeness: per project assets missing interfaces/leaks/test parameters, vulnerability records with incomplete evidence, internal-scope entity counts and snapshot counts. Run this after a testing phase and fill every reported gap before finishing.',
|
|
857
|
+
{ project_name: { type: 'string', description: 'Optional project filter (canonical name)' } },
|
|
858
|
+
[],
|
|
859
|
+
async (args) => ({ text: auditCompleteness({ holder, args }).text }),
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
const internalSyncTool = defineTool(
|
|
863
|
+
'cortex_sync_internal_scope',
|
|
864
|
+
'Register internal operations data for a project (schema v3): internal_hosts, internal_services, footholds, credentials and pivots. Call it after initial access and whenever new internal hosts/services/credentials are found. Arrays are upserted; footholds accept host_ips to link reachable hosts, pivots accept hops [{ref_kind, ref_id, label?, via?}].',
|
|
865
|
+
{
|
|
866
|
+
project_name: { type: 'string', minLength: 1, maxLength: 120 },
|
|
867
|
+
internal_hosts: {
|
|
868
|
+
type: 'array',
|
|
869
|
+
description: 'Example: {ip: "10.0.0.5", hostname: "db01", os: "Ubuntu 22.04", subnet: "10.0.0.0/24", state: "alive", source: "nmap"}',
|
|
870
|
+
items: {
|
|
871
|
+
type: 'object',
|
|
872
|
+
properties: {
|
|
873
|
+
ip: { type: 'string' },
|
|
874
|
+
hostname: { type: 'string' },
|
|
875
|
+
os: { type: 'string' },
|
|
876
|
+
subnet: { type: 'string' },
|
|
877
|
+
state: { type: 'string', enum: ['unknown', 'alive', 'compromised', 'unreachable', 'firewalled'] },
|
|
878
|
+
source: { type: 'string' },
|
|
879
|
+
confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
880
|
+
},
|
|
881
|
+
required: ['ip'],
|
|
882
|
+
},
|
|
883
|
+
},
|
|
884
|
+
internal_services: {
|
|
885
|
+
type: 'array',
|
|
886
|
+
description: 'Example: {ip: "10.0.0.5", port: 6379, protocol: "tcp", service: "redis", banner: "Redis 7.4", state: "open"}',
|
|
887
|
+
items: {
|
|
888
|
+
type: 'object',
|
|
889
|
+
properties: {
|
|
890
|
+
ip: { type: 'string' },
|
|
891
|
+
host_id: { type: 'string' },
|
|
892
|
+
port: { type: 'integer' },
|
|
893
|
+
protocol: { type: 'string' },
|
|
894
|
+
service: { type: 'string' },
|
|
895
|
+
banner: { type: 'string' },
|
|
896
|
+
state: { type: 'string', enum: ['open', 'closed', 'filtered', 'unknown'] },
|
|
897
|
+
source: { type: 'string' },
|
|
898
|
+
},
|
|
899
|
+
required: ['port'],
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
footholds: {
|
|
903
|
+
type: 'array',
|
|
904
|
+
description: 'Example: {entry_host_port: "https://target/", entry_vuln_id: "VULN-...", access_type: "rce", privilege_level: "admin", host_ips: ["10.0.0.5"], evidence: "id; whoami", notes: "..."}',
|
|
905
|
+
items: {
|
|
906
|
+
type: 'object',
|
|
907
|
+
properties: {
|
|
908
|
+
entry_host_port: { type: 'string' },
|
|
909
|
+
entry_vuln_id: { type: 'string' },
|
|
910
|
+
access_type: { type: 'string', enum: ['rce', 'webshell', 'ssrf_proxy', 'credential', 'vpn', 'other'] },
|
|
911
|
+
privilege_level: { type: 'string', enum: ['user', 'admin', 'system', 'domain_admin', 'unknown'] },
|
|
912
|
+
status: { type: 'string', enum: ['planned', 'established', 'degraded', 'lost'] },
|
|
913
|
+
evidence: { type: 'string' },
|
|
914
|
+
notes: { type: 'string' },
|
|
915
|
+
host_ips: { type: 'array', items: { type: 'string' } },
|
|
916
|
+
},
|
|
917
|
+
required: ['entry_host_port'],
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
credentials: {
|
|
921
|
+
type: 'array',
|
|
922
|
+
description: 'Example: {type: "password", username: "deploy", secret: "***", host: "10.0.0.5", service: "ssh", source: "backup.sh", confidence: "high", status: "valid"}',
|
|
923
|
+
items: {
|
|
924
|
+
type: 'object',
|
|
925
|
+
properties: {
|
|
926
|
+
type: { type: 'string', enum: ['password', 'ntlm_hash', 'netntlmv2', 'kerberos_ticket', 'ssh_key', 'cert', 'api_key', 'other'] },
|
|
927
|
+
username: { type: 'string' },
|
|
928
|
+
domain: { type: 'string' },
|
|
929
|
+
secret: { type: 'string' },
|
|
930
|
+
host: { type: 'string' },
|
|
931
|
+
port: { type: 'integer' },
|
|
932
|
+
protocol: { type: 'string' },
|
|
933
|
+
service: { type: 'string' },
|
|
934
|
+
source: { type: 'string' },
|
|
935
|
+
confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
936
|
+
status: { type: 'string', enum: ['untested', 'valid', 'invalid', 'expired'] },
|
|
937
|
+
notes: { type: 'string' },
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
pivots: {
|
|
942
|
+
type: 'array',
|
|
943
|
+
description: 'Example: {entry_host_port: "https://target/", kind: "proxy", local_port: 1080, local_protocol: "socks5", tool: "chisel", status: "established"}',
|
|
944
|
+
items: {
|
|
945
|
+
type: 'object',
|
|
946
|
+
properties: {
|
|
947
|
+
foothold_id: { type: 'string' },
|
|
948
|
+
entry_host_port: { type: 'string' },
|
|
949
|
+
kind: { type: 'string', enum: ['chain', 'proxy'] },
|
|
950
|
+
local_host: { type: 'string' },
|
|
951
|
+
local_port: { type: 'integer' },
|
|
952
|
+
local_protocol: { type: 'string' },
|
|
953
|
+
tool: { type: 'string' },
|
|
954
|
+
status: { type: 'string', enum: ['planned', 'established', 'degraded', 'lost'] },
|
|
955
|
+
notes: { type: 'string' },
|
|
956
|
+
hops: { type: 'array', items: { type: 'object' } },
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
},
|
|
961
|
+
['project_name'],
|
|
962
|
+
async (args) => {
|
|
963
|
+
assertWritable(holder)
|
|
964
|
+
return { text: syncInternalScope({ holder, args }).text }
|
|
965
|
+
},
|
|
966
|
+
)
|
|
967
|
+
|
|
653
968
|
const tools = [
|
|
654
969
|
listVulns,
|
|
655
970
|
getVuln,
|
|
@@ -661,8 +976,11 @@ export function createCortexToolDefinitions({ holder, config }) {
|
|
|
661
976
|
getAsset,
|
|
662
977
|
listSnapshots,
|
|
663
978
|
getSnapshot,
|
|
979
|
+
saveSnapshot,
|
|
664
980
|
internalOverview,
|
|
665
981
|
graphSummary,
|
|
982
|
+
auditTool,
|
|
983
|
+
internalSyncTool,
|
|
666
984
|
]
|
|
667
985
|
return readOnly ? tools.map((tool) => ({ ...tool, description: `${tool.description} (current host: read-only)` })) : tools
|
|
668
986
|
}
|