@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,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-completeness tools for the agent: internal-scope import and a read-only
|
|
3
|
+
* audit that points out missing asset/recon/snapshot data.
|
|
4
|
+
*
|
|
5
|
+
* Kept separate from tools.js so the tool registrations stay declarative.
|
|
6
|
+
*/
|
|
7
|
+
import { withBusyRetry } from '../db.js'
|
|
8
|
+
import {
|
|
9
|
+
normalizeInterfaces,
|
|
10
|
+
normalizeLeaks,
|
|
11
|
+
normalizeProjectName,
|
|
12
|
+
normalizeTestParameters,
|
|
13
|
+
} from './normalize-asset.js'
|
|
14
|
+
|
|
15
|
+
const LOG = '[cortex]'
|
|
16
|
+
|
|
17
|
+
const ACCESS_TYPES = ['rce', 'webshell', 'ssrf_proxy', 'credential', 'vpn', 'other']
|
|
18
|
+
const PRIVILEGE_LEVELS = ['user', 'admin', 'system', 'domain_admin', 'unknown']
|
|
19
|
+
const FOOTHOLD_STATUSES = ['planned', 'established', 'degraded', 'lost']
|
|
20
|
+
const HOST_STATES = ['unknown', 'alive', 'compromised', 'unreachable', 'firewalled']
|
|
21
|
+
const CONFIDENCES = ['low', 'medium', 'high']
|
|
22
|
+
const SERVICE_STATES = ['open', 'closed', 'filtered', 'unknown']
|
|
23
|
+
const PIVOT_KINDS = ['chain', 'proxy']
|
|
24
|
+
const PIVOT_STATUSES = ['planned', 'established', 'degraded', 'lost']
|
|
25
|
+
const CREDENTIAL_TYPES = ['password', 'ntlm_hash', 'netntlmv2', 'kerberos_ticket', 'ssh_key', 'cert', 'api_key', 'other']
|
|
26
|
+
const CREDENTIAL_STATUSES = ['untested', 'valid', 'invalid', 'expired']
|
|
27
|
+
|
|
28
|
+
const text = (value) => (typeof value === 'string' ? value.trim() : '')
|
|
29
|
+
const num = (value, fallback) => (Number.isInteger(Number(value)) ? Number(value) : fallback)
|
|
30
|
+
const oneOf = (allowed, value, fallback) => (allowed.includes(value) ? value : fallback)
|
|
31
|
+
const list = (value) => (Array.isArray(value) ? value.filter((item) => item && typeof item === 'object') : [])
|
|
32
|
+
|
|
33
|
+
function ensureDb(holder) {
|
|
34
|
+
const status = holder.ensure()
|
|
35
|
+
if (!status.compatible || holder.db === null) {
|
|
36
|
+
throw new Error(`Cortex database unavailable: ${status.error || `schema ${status.schemaVersion}`}`)
|
|
37
|
+
}
|
|
38
|
+
return holder.db
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runWrite(db, work) {
|
|
42
|
+
return withBusyRetry(() => {
|
|
43
|
+
db.exec('BEGIN IMMEDIATE')
|
|
44
|
+
try {
|
|
45
|
+
const result = work()
|
|
46
|
+
db.exec('COMMIT')
|
|
47
|
+
return result
|
|
48
|
+
} catch (error) {
|
|
49
|
+
try {
|
|
50
|
+
db.exec('ROLLBACK')
|
|
51
|
+
} catch {
|
|
52
|
+
/* ignore */
|
|
53
|
+
}
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function newId(prefix) {
|
|
60
|
+
const uuid = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`
|
|
61
|
+
const hex = String(uuid).replace(/-/g, '').replace(/[^0-9a-f]/gi, '').toUpperCase()
|
|
62
|
+
return `${prefix}${hex.slice(0, 12).padEnd(12, '0')}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* ------------------------------------------------------- internal import -- */
|
|
66
|
+
|
|
67
|
+
export function syncInternalScope({ holder, args }) {
|
|
68
|
+
const db = ensureDb(holder)
|
|
69
|
+
const project = normalizeProjectName(text(args.project_name))
|
|
70
|
+
if (project.length === 0) throw new Error('project_name is required')
|
|
71
|
+
const now = new Date().toISOString()
|
|
72
|
+
const counts = { hosts: 0, services: 0, footholds: 0, links: 0, credentials: 0, pivots: 0, hops: 0 }
|
|
73
|
+
const warnings = []
|
|
74
|
+
const hostIdByIp = new Map()
|
|
75
|
+
|
|
76
|
+
const findHost = (ip) => {
|
|
77
|
+
if (hostIdByIp.has(ip)) return hostIdByIp.get(ip)
|
|
78
|
+
const row = db.prepare('SELECT id FROM internal_hosts WHERE project_name = ? AND ip = ?').get(project, ip)
|
|
79
|
+
if (row) hostIdByIp.set(ip, row.id)
|
|
80
|
+
return row ? row.id : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
runWrite(db, () => {
|
|
84
|
+
for (const host of list(args.internal_hosts)) {
|
|
85
|
+
const ip = text(host.ip ?? host.host)
|
|
86
|
+
if (ip.length === 0) {
|
|
87
|
+
warnings.push('internal_hosts: entry without ip skipped')
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
let id = findHost(ip)
|
|
91
|
+
if (!id) {
|
|
92
|
+
id = newId('IH-')
|
|
93
|
+
db.prepare(
|
|
94
|
+
`INSERT INTO internal_hosts
|
|
95
|
+
(id, project_name, ip, hostname, os, subnet, state, source, confidence, last_seen, created_at, updated_at)
|
|
96
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
97
|
+
).run(
|
|
98
|
+
id,
|
|
99
|
+
project,
|
|
100
|
+
ip,
|
|
101
|
+
text(host.hostname),
|
|
102
|
+
text(host.os),
|
|
103
|
+
text(host.subnet),
|
|
104
|
+
oneOf(HOST_STATES, text(host.state), 'alive'),
|
|
105
|
+
text(host.source),
|
|
106
|
+
oneOf(CONFIDENCES, text(host.confidence), 'medium'),
|
|
107
|
+
text(host.last_seen) || now,
|
|
108
|
+
now,
|
|
109
|
+
now,
|
|
110
|
+
)
|
|
111
|
+
hostIdByIp.set(ip, id)
|
|
112
|
+
counts.hosts += 1
|
|
113
|
+
} else {
|
|
114
|
+
const fields = [
|
|
115
|
+
['hostname', text(host.hostname)],
|
|
116
|
+
['os', text(host.os)],
|
|
117
|
+
['subnet', text(host.subnet)],
|
|
118
|
+
['state', oneOf(HOST_STATES, text(host.state), undefined)],
|
|
119
|
+
['source', text(host.source)],
|
|
120
|
+
['confidence', oneOf(CONFIDENCES, text(host.confidence), undefined)],
|
|
121
|
+
].filter(([, value]) => value !== undefined && value !== '')
|
|
122
|
+
if (fields.length > 0) {
|
|
123
|
+
db.prepare(
|
|
124
|
+
`UPDATE internal_hosts SET ${fields.map(([column]) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
|
125
|
+
).run(...fields.map(([, value]) => value), now, id)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const serviceIdByKey = new Map()
|
|
131
|
+
for (const service of list(args.internal_services)) {
|
|
132
|
+
const hostId = text(service.host_id) || findHost(text(service.ip ?? service.host))
|
|
133
|
+
const port = num(service.port, 0)
|
|
134
|
+
if (!hostId || port < 1 || port > 65535) {
|
|
135
|
+
warnings.push('internal_services: entry without resolvable host/port skipped')
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const protocol = text(service.protocol) || 'tcp'
|
|
139
|
+
const key = `${hostId}:${port}:${protocol}`
|
|
140
|
+
const existing = db
|
|
141
|
+
.prepare('SELECT id FROM internal_services WHERE host_id = ? AND port = ? AND protocol = ?')
|
|
142
|
+
.get(hostId, port, protocol)
|
|
143
|
+
let id = existing ? existing.id : undefined
|
|
144
|
+
if (!id) {
|
|
145
|
+
id = newId('IS-')
|
|
146
|
+
db.prepare(
|
|
147
|
+
`INSERT INTO internal_services
|
|
148
|
+
(id, project_name, host_id, port, protocol, service, banner, state, source, last_seen, created_at, updated_at)
|
|
149
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
150
|
+
).run(
|
|
151
|
+
id,
|
|
152
|
+
project,
|
|
153
|
+
hostId,
|
|
154
|
+
port,
|
|
155
|
+
protocol,
|
|
156
|
+
text(service.service),
|
|
157
|
+
text(service.banner),
|
|
158
|
+
oneOf(SERVICE_STATES, text(service.state), 'open'),
|
|
159
|
+
text(service.source),
|
|
160
|
+
text(service.last_seen) || now,
|
|
161
|
+
now,
|
|
162
|
+
now,
|
|
163
|
+
)
|
|
164
|
+
counts.services += 1
|
|
165
|
+
} else {
|
|
166
|
+
db.prepare(
|
|
167
|
+
`UPDATE internal_services SET service = COALESCE(NULLIF(?, ''), service),
|
|
168
|
+
banner = COALESCE(NULLIF(?, ''), banner), updated_at = ? WHERE id = ?`,
|
|
169
|
+
).run(text(service.service), text(service.banner), now, id)
|
|
170
|
+
}
|
|
171
|
+
serviceIdByKey.set(key, id)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const footholdIdByEntry = new Map()
|
|
175
|
+
for (const foothold of list(args.footholds)) {
|
|
176
|
+
const entry = text(foothold.entry_host_port)
|
|
177
|
+
if (entry.length === 0) {
|
|
178
|
+
warnings.push('footholds: entry without entry_host_port skipped')
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
const existing = db
|
|
182
|
+
.prepare('SELECT id FROM footholds WHERE project_name = ? AND entry_host_port = ?')
|
|
183
|
+
.get(project, entry)
|
|
184
|
+
let id = existing ? existing.id : undefined
|
|
185
|
+
if (!id) {
|
|
186
|
+
id = newId('IF-')
|
|
187
|
+
db.prepare(
|
|
188
|
+
`INSERT INTO footholds
|
|
189
|
+
(id, project_name, entry_host_port, entry_vuln_id, access_type, privilege_level, status, evidence, notes,
|
|
190
|
+
established_at, last_verified_at, created_at, updated_at)
|
|
191
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
192
|
+
).run(
|
|
193
|
+
id,
|
|
194
|
+
project,
|
|
195
|
+
entry,
|
|
196
|
+
text(foothold.entry_vuln_id),
|
|
197
|
+
oneOf(ACCESS_TYPES, text(foothold.access_type), 'other'),
|
|
198
|
+
oneOf(PRIVILEGE_LEVELS, text(foothold.privilege_level), 'unknown'),
|
|
199
|
+
oneOf(FOOTHOLD_STATUSES, text(foothold.status), 'established'),
|
|
200
|
+
text(foothold.evidence),
|
|
201
|
+
text(foothold.notes),
|
|
202
|
+
text(foothold.established_at) || now,
|
|
203
|
+
text(foothold.last_verified_at) || now,
|
|
204
|
+
now,
|
|
205
|
+
now,
|
|
206
|
+
)
|
|
207
|
+
counts.footholds += 1
|
|
208
|
+
} else {
|
|
209
|
+
db.prepare(
|
|
210
|
+
`UPDATE footholds SET access_type = COALESCE(NULLIF(?, ''), access_type),
|
|
211
|
+
privilege_level = COALESCE(NULLIF(?, ''), privilege_level),
|
|
212
|
+
status = COALESCE(NULLIF(?, ''), status), notes = COALESCE(NULLIF(?, ''), notes), updated_at = ?
|
|
213
|
+
WHERE id = ?`,
|
|
214
|
+
).run(
|
|
215
|
+
oneOf(ACCESS_TYPES, text(foothold.access_type), '') || '',
|
|
216
|
+
oneOf(PRIVILEGE_LEVELS, text(foothold.privilege_level), '') || '',
|
|
217
|
+
oneOf(FOOTHOLD_STATUSES, text(foothold.status), '') || '',
|
|
218
|
+
text(foothold.notes),
|
|
219
|
+
now,
|
|
220
|
+
id,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
footholdIdByEntry.set(entry, id)
|
|
224
|
+
const relatedHosts = Array.isArray(foothold.host_ips) ? foothold.host_ips : []
|
|
225
|
+
for (const ipValue of relatedHosts) {
|
|
226
|
+
const ip = text(ipValue)
|
|
227
|
+
if (ip.length === 0) continue
|
|
228
|
+
const hostId = findHost(ip)
|
|
229
|
+
if (!hostId) {
|
|
230
|
+
warnings.push(`footholds: host ${ip} not registered before foothold; add it to internal_hosts`)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
db.prepare(
|
|
234
|
+
'INSERT OR IGNORE INTO foothold_hosts (foothold_id, internal_host_id, discovered_at, notes) VALUES (?,?,?,?)',
|
|
235
|
+
).run(id, hostId, now, '')
|
|
236
|
+
counts.links += 1
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (const credential of list(args.credentials)) {
|
|
241
|
+
const type = oneOf(CREDENTIAL_TYPES, text(credential.type), 'other')
|
|
242
|
+
const hostId = text(credential.host_id) || findHost(text(credential.ip ?? credential.host))
|
|
243
|
+
const serviceKey = `${hostId ?? ''}:${num(credential.port, 0)}:${text(credential.protocol) || 'tcp'}`
|
|
244
|
+
db.prepare(
|
|
245
|
+
`INSERT INTO credentials
|
|
246
|
+
(id, project_name, type, username, domain, secret, source, host_id, service_id, confidence, status, notes, created_at, updated_at)
|
|
247
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
248
|
+
).run(
|
|
249
|
+
newId('IC-'),
|
|
250
|
+
project,
|
|
251
|
+
type,
|
|
252
|
+
text(credential.username),
|
|
253
|
+
text(credential.domain),
|
|
254
|
+
text(credential.secret),
|
|
255
|
+
text(credential.source),
|
|
256
|
+
hostId ?? null,
|
|
257
|
+
serviceIdByKey.get(serviceKey) ?? null,
|
|
258
|
+
oneOf(CONFIDENCES, text(credential.confidence), 'medium'),
|
|
259
|
+
oneOf(CREDENTIAL_STATUSES, text(credential.status), 'untested'),
|
|
260
|
+
text(credential.notes),
|
|
261
|
+
now,
|
|
262
|
+
now,
|
|
263
|
+
)
|
|
264
|
+
counts.credentials += 1
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const pivot of list(args.pivots)) {
|
|
268
|
+
const footholdId =
|
|
269
|
+
text(pivot.foothold_id) || footholdIdByEntry.get(text(pivot.entry_host_port)) || findFoothold(db, project, text(pivot.entry_host_port))
|
|
270
|
+
if (!footholdId) {
|
|
271
|
+
warnings.push('pivots: entry without a registered foothold skipped')
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
const kind = oneOf(PIVOT_KINDS, text(pivot.kind), 'chain')
|
|
275
|
+
const localPort = num(pivot.local_port, 0)
|
|
276
|
+
const id = newId('IP-')
|
|
277
|
+
db.prepare(
|
|
278
|
+
`INSERT INTO pivots
|
|
279
|
+
(id, project_name, foothold_id, kind, local_host, local_port, local_protocol, tool, status, notes,
|
|
280
|
+
established_at, last_verified_at, created_at, updated_at)
|
|
281
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
282
|
+
).run(
|
|
283
|
+
id,
|
|
284
|
+
project,
|
|
285
|
+
footholdId,
|
|
286
|
+
kind,
|
|
287
|
+
text(pivot.local_host) || '127.0.0.1',
|
|
288
|
+
localPort,
|
|
289
|
+
text(pivot.local_protocol),
|
|
290
|
+
text(pivot.tool),
|
|
291
|
+
oneOf(PIVOT_STATUSES, text(pivot.status), 'planned'),
|
|
292
|
+
text(pivot.notes),
|
|
293
|
+
text(pivot.established_at) || now,
|
|
294
|
+
text(pivot.last_verified_at) || now,
|
|
295
|
+
now,
|
|
296
|
+
now,
|
|
297
|
+
)
|
|
298
|
+
counts.pivots += 1
|
|
299
|
+
const hops = Array.isArray(pivot.hops) ? pivot.hops : []
|
|
300
|
+
hops.forEach((hop, index) => {
|
|
301
|
+
const refKind = hop && hop.ref_kind === 'internal_service' ? 'internal_service' : 'internal_host'
|
|
302
|
+
const refId = text(hop && hop.ref_id)
|
|
303
|
+
if (refId.length === 0) return
|
|
304
|
+
db.prepare(
|
|
305
|
+
'INSERT INTO pivot_hops (pivot_id, seq, ref_kind, ref_id, label, via) VALUES (?,?,?,?,?,?)',
|
|
306
|
+
).run(id, index, refKind, refId, text(hop && hop.label), text(hop && hop.via))
|
|
307
|
+
counts.hops += 1
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
holder.refreshFileStamp()
|
|
313
|
+
const summary = [
|
|
314
|
+
`${LOG} internal scope updated for project "${project}"`,
|
|
315
|
+
`hosts +${counts.hosts}, services +${counts.services}, footholds +${counts.footholds}, host links +${counts.links}, credentials +${counts.credentials}, pivots +${counts.pivots}, hops +${counts.hops}`,
|
|
316
|
+
]
|
|
317
|
+
if (warnings.length > 0) summary.push(...warnings.slice(0, 8).map((warning) => `warning: ${warning}`))
|
|
318
|
+
return { text: summary.join('\n') }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function findFoothold(db, project, entry) {
|
|
322
|
+
if (!entry) return undefined
|
|
323
|
+
const row = db.prepare('SELECT id FROM footholds WHERE project_name = ? AND entry_host_port = ?').get(project, entry)
|
|
324
|
+
return row ? row.id : undefined
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ------------------------------------------------------------- audit -- */
|
|
328
|
+
|
|
329
|
+
export function auditCompleteness({ holder, args }) {
|
|
330
|
+
const db = ensureDb(holder)
|
|
331
|
+
const projectFilter = normalizeProjectName(text(args.project_name))
|
|
332
|
+
const assets = db
|
|
333
|
+
.prepare('SELECT host_port, project_name, interfaces, leaked_infos, test_parameters, tech_stack FROM assets')
|
|
334
|
+
.all()
|
|
335
|
+
const grouped = new Map()
|
|
336
|
+
for (const row of assets) {
|
|
337
|
+
const project = normalizeProjectName(row.project_name)
|
|
338
|
+
if (projectFilter && project !== projectFilter) continue
|
|
339
|
+
const entry = grouped.get(project) ?? {
|
|
340
|
+
assets: 0,
|
|
341
|
+
missingInterfaces: 0,
|
|
342
|
+
missingLeaks: 0,
|
|
343
|
+
missingParameters: 0,
|
|
344
|
+
missingTech: 0,
|
|
345
|
+
}
|
|
346
|
+
entry.assets += 1
|
|
347
|
+
if (normalizeInterfaces(row.interfaces, row.interface_dir).length === 0) entry.missingInterfaces += 1
|
|
348
|
+
if (normalizeLeaks(row.leaked_infos).length === 0) entry.missingLeaks += 1
|
|
349
|
+
if (normalizeTestParameters(row.test_parameters).length === 0) entry.missingParameters += 1
|
|
350
|
+
if (text(row.tech_stack).length === 0) entry.missingTech += 1
|
|
351
|
+
grouped.set(project, entry)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const vulns = db.prepare('SELECT id, vuln_name, description, impact, reproduction_steps, related_vuln_ids FROM vulnerabilities').all()
|
|
355
|
+
const missingRepro = vulns.filter((row) => text(row.reproduction_steps).length < 50)
|
|
356
|
+
const missingImpact = vulns.filter((row) => text(row.impact).length === 0)
|
|
357
|
+
const missingDescription = vulns.filter((row) => text(row.description).length === 0)
|
|
358
|
+
|
|
359
|
+
const countByProject = (table) => {
|
|
360
|
+
const counts = new Map()
|
|
361
|
+
for (const row of db.prepare(`SELECT project_name FROM ${table}`).all()) {
|
|
362
|
+
const project = normalizeProjectName(row.project_name)
|
|
363
|
+
counts.set(project, (counts.get(project) || 0) + 1)
|
|
364
|
+
}
|
|
365
|
+
return counts
|
|
366
|
+
}
|
|
367
|
+
const footholdCounts = countByProject('footholds')
|
|
368
|
+
const hostCounts = countByProject('internal_hosts')
|
|
369
|
+
const serviceCounts = countByProject('internal_services')
|
|
370
|
+
const credentialCounts = countByProject('credentials')
|
|
371
|
+
const pivotCounts = countByProject('pivots')
|
|
372
|
+
const snapshotCounts = countByProject('snapshot_sessions')
|
|
373
|
+
|
|
374
|
+
const lines = [`${LOG} completeness audit${projectFilter ? ` (project: ${projectFilter})` : ''}`]
|
|
375
|
+
for (const [project, entry] of grouped) {
|
|
376
|
+
const count = (map) => map.get(project) || 0
|
|
377
|
+
lines.push(
|
|
378
|
+
`- ${project}: assets ${entry.assets} (missing interfaces ${entry.missingInterfaces}, leaks ${entry.missingLeaks}, test parameters ${entry.missingParameters}, tech stack ${entry.missingTech})`,
|
|
379
|
+
)
|
|
380
|
+
lines.push(
|
|
381
|
+
` internal: footholds ${count(footholdCounts)}, hosts ${count(hostCounts)}, services ${count(serviceCounts)}, credentials ${count(credentialCounts)}, pivots ${count(pivotCounts)}; snapshots ${count(snapshotCounts)}`,
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
lines.push(
|
|
385
|
+
`${LOG} vulnerabilities: ${vulns.length}; missing reproduction_steps ${missingRepro.length}, description ${missingDescription.length}, impact ${missingImpact.length}`,
|
|
386
|
+
)
|
|
387
|
+
if (missingRepro.length > 0) lines.push(` incomplete findings: ${missingRepro.slice(0, 8).map((row) => row.id).join(', ')}`)
|
|
388
|
+
if (grouped.size === 0) lines.push(' no assets registered yet')
|
|
389
|
+
lines.push(
|
|
390
|
+
'Fill gaps with cortex_sync_asset_info (interfaces/leaks/test_parameters), cortex_save_vuln (complete reproduction), cortex_sync_internal_scope (internal ops) and cortex_save_snapshot (task progress).',
|
|
391
|
+
)
|
|
392
|
+
return { text: lines.join('\n') }
|
|
393
|
+
}
|
|
@@ -52,6 +52,12 @@ import {
|
|
|
52
52
|
normalizeAssetIdentifier,
|
|
53
53
|
normalizeAssetInterfaces,
|
|
54
54
|
} from './shared/types.js'
|
|
55
|
+
import {
|
|
56
|
+
normalizeInterfaces,
|
|
57
|
+
normalizeLeaks,
|
|
58
|
+
normalizeProjectName,
|
|
59
|
+
normalizeTestParameters,
|
|
60
|
+
} from './normalize-asset.js'
|
|
55
61
|
import {
|
|
56
62
|
INTERNAL_LIMITS,
|
|
57
63
|
buildCredentialSuggestions,
|
|
@@ -168,16 +174,17 @@ const toLootImportStatus = (value) => toStringList(LOOT_IMPORT_STATUSES, value,
|
|
|
168
174
|
|
|
169
175
|
/** Extract the nine cognitive-state fields from a stored snapshot row. */
|
|
170
176
|
function snapshotPayloadFromRow(row) {
|
|
177
|
+
const str = (value) => (typeof value === 'string' ? value : '')
|
|
171
178
|
return {
|
|
172
|
-
project_name: row.project_name,
|
|
173
|
-
target_hosts: parseJsonArray(row.target_hosts),
|
|
174
|
-
current_phase: row.current_phase,
|
|
179
|
+
project_name: normalizeProjectName(str(row.project_name)),
|
|
180
|
+
target_hosts: parseJsonArray(row.target_hosts).filter((item) => typeof item === 'string'),
|
|
181
|
+
current_phase: str(row.current_phase),
|
|
175
182
|
unfinished_tasks: parseJsonArray(row.unfinished_tasks),
|
|
176
183
|
failed_paths: parseJsonArray(row.failed_paths),
|
|
177
184
|
tested_interfaces: parseJsonArray(row.tested_interfaces),
|
|
178
|
-
notes: row.notes
|
|
179
|
-
related_vuln_ids: parseJsonArray(row.related_vuln_ids),
|
|
180
|
-
next_steps: row.next_steps,
|
|
185
|
+
notes: str(row.notes),
|
|
186
|
+
related_vuln_ids: parseJsonArray(row.related_vuln_ids).filter((item) => typeof item === 'string'),
|
|
187
|
+
next_steps: str(row.next_steps),
|
|
181
188
|
}
|
|
182
189
|
}
|
|
183
190
|
|
|
@@ -228,7 +235,7 @@ function canonicalizeTestParameters(rawValues) {
|
|
|
228
235
|
}
|
|
229
236
|
|
|
230
237
|
function parseLeakedInfos(raw) {
|
|
231
|
-
return
|
|
238
|
+
return normalizeLeaks(raw)
|
|
232
239
|
}
|
|
233
240
|
|
|
234
241
|
function emptyScopeCounts() {
|
|
@@ -248,22 +255,19 @@ function generateInternalId(prefix) {
|
|
|
248
255
|
/* ---------------------------------------------------------------- mappers -- */
|
|
249
256
|
|
|
250
257
|
function mapAssetRow(row) {
|
|
251
|
-
const interfaces =
|
|
252
|
-
parseJsonArray(row.interfaces ?? '[]'),
|
|
253
|
-
parseJsonArray(row.interface_dir),
|
|
254
|
-
)
|
|
258
|
+
const interfaces = normalizeInterfaces(row.interfaces, row.interface_dir)
|
|
255
259
|
return {
|
|
256
260
|
host_port: row.host_port,
|
|
257
261
|
host: row.host || deriveAssetHost(row.host_port),
|
|
258
|
-
project_name: row.project_name,
|
|
262
|
+
project_name: normalizeProjectName(row.project_name),
|
|
259
263
|
tech_stack: row.tech_stack ?? '',
|
|
260
264
|
auth_required: row.auth_required === 1,
|
|
261
265
|
status: toAssetStatus(row.status),
|
|
262
266
|
interfaces,
|
|
263
267
|
interface_dir: interfaceDirFromInterfaces(interfaces),
|
|
264
|
-
leaked_infos:
|
|
265
|
-
test_parameters:
|
|
266
|
-
related_vuln_ids: parseJsonArray(row.related_vuln_ids),
|
|
268
|
+
leaked_infos: normalizeLeaks(row.leaked_infos),
|
|
269
|
+
test_parameters: normalizeTestParameters(row.test_parameters),
|
|
270
|
+
related_vuln_ids: parseJsonArray(row.related_vuln_ids).filter((item) => typeof item === 'string'),
|
|
267
271
|
last_seen: row.last_seen,
|
|
268
272
|
}
|
|
269
273
|
}
|
|
@@ -284,7 +288,7 @@ function mapSnapshotRow(row) {
|
|
|
284
288
|
function mapFootholdRow(row, hostIds) {
|
|
285
289
|
return {
|
|
286
290
|
id: row.id,
|
|
287
|
-
project_name: row.project_name,
|
|
291
|
+
project_name: normalizeProjectName(row.project_name),
|
|
288
292
|
entry_host_port: row.entry_host_port,
|
|
289
293
|
entry_vuln_id: row.entry_vuln_id,
|
|
290
294
|
access_type: toFootholdAccessType(row.access_type),
|
|
@@ -303,7 +307,7 @@ function mapFootholdRow(row, hostIds) {
|
|
|
303
307
|
function mapInternalHostRow(row) {
|
|
304
308
|
return {
|
|
305
309
|
id: row.id,
|
|
306
|
-
project_name: row.project_name,
|
|
310
|
+
project_name: normalizeProjectName(row.project_name),
|
|
307
311
|
ip: row.ip,
|
|
308
312
|
hostname: row.hostname,
|
|
309
313
|
os: row.os,
|
|
@@ -321,7 +325,7 @@ function mapInternalHostRow(row) {
|
|
|
321
325
|
function mapInternalServiceRow(row) {
|
|
322
326
|
return {
|
|
323
327
|
id: row.id,
|
|
324
|
-
project_name: row.project_name,
|
|
328
|
+
project_name: normalizeProjectName(row.project_name),
|
|
325
329
|
host_id: row.host_id,
|
|
326
330
|
port: row.port,
|
|
327
331
|
protocol: row.protocol,
|
|
@@ -344,7 +348,7 @@ function mapInternalServiceRow(row) {
|
|
|
344
348
|
function mapCredentialRow(row) {
|
|
345
349
|
return {
|
|
346
350
|
id: row.id,
|
|
347
|
-
project_name: row.project_name,
|
|
351
|
+
project_name: normalizeProjectName(row.project_name),
|
|
348
352
|
type: toCredentialType(row.type),
|
|
349
353
|
username: row.username,
|
|
350
354
|
domain: row.domain,
|
|
@@ -364,7 +368,7 @@ function mapCredentialRow(row) {
|
|
|
364
368
|
function mapPivotRow(row, hops, reachableSubnets) {
|
|
365
369
|
return {
|
|
366
370
|
id: row.id,
|
|
367
|
-
project_name: row.project_name,
|
|
371
|
+
project_name: normalizeProjectName(row.project_name),
|
|
368
372
|
foothold_id: row.foothold_id,
|
|
369
373
|
kind: toPivotKind(row.kind),
|
|
370
374
|
local_host: row.local_host,
|
|
@@ -397,7 +401,7 @@ function mapCredentialTrialRow(row) {
|
|
|
397
401
|
function mapLootImportRow(row) {
|
|
398
402
|
return {
|
|
399
403
|
id: row.id,
|
|
400
|
-
project_name: row.project_name,
|
|
404
|
+
project_name: normalizeProjectName(row.project_name),
|
|
401
405
|
entry_host_port: row.entry_host_port,
|
|
402
406
|
filename: row.filename,
|
|
403
407
|
sha256: row.sha256,
|
|
@@ -664,7 +668,7 @@ function listInternalProjects(db) {
|
|
|
664
668
|
)
|
|
665
669
|
.all()
|
|
666
670
|
return rows.map((row) => ({
|
|
667
|
-
project_name: row.project_name,
|
|
671
|
+
project_name: normalizeProjectName(row.project_name),
|
|
668
672
|
counts: countInternalScope(db, row.project_name),
|
|
669
673
|
}))
|
|
670
674
|
}
|