@lievclouds/dsh-cortex 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

@@ -0,0 +1,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 parseJsonArray(raw)
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 = normalizeAssetInterfaces(
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: parseLeakedInfos(row.leaked_infos),
265
- test_parameters: canonicalizeTestParameters(parseJsonArray(row.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
  }
@@ -85,6 +85,9 @@ const ROUTES = [
85
85
 
86
86
  { pattern: /^\/snapshots$/, methods: { GET: snapshots.handleList } },
87
87
  { pattern: /^\/snapshots\/([^/]+)\/archive$/, methods: { POST: snapshots.handleArchive } },
88
+ // Both spellings exist: the vendored renderer's original Web API uses
89
+ // `/restore`, while the desktop contract names it `restore-previous`.
90
+ { pattern: /^\/snapshots\/([^/]+)\/restore$/, methods: { POST: snapshots.handleRestorePrevious } },
88
91
  {
89
92
  pattern: /^\/snapshots\/([^/]+)\/restore-previous$/,
90
93
  methods: { POST: snapshots.handleRestorePrevious },
@@ -132,19 +135,21 @@ function assertWriteFence(req, method) {
132
135
  }
133
136
  const headers = (req && req.headers) || {}
134
137
  const origin = headers.origin
138
+ const fetchSite = String(headers['sec-fetch-site'] || '').trim().toLowerCase()
135
139
  if (origin !== undefined && origin !== null && String(origin).trim().length > 0) {
136
140
  if (!isLoopbackOrigin(String(origin))) {
137
- throw forbidden('FORBIDDEN_ORIGIN', 'write request origin is not allowed')
141
+ // DSH renders the plugin from the custom `dsh-app://app` origin; its
142
+ // same-origin DELETE carries `Sec-Fetch-Site: same-origin`, which is the
143
+ // signal we trust (a cross-site page would report cross-site).
144
+ if (fetchSite !== 'same-origin' && fetchSite !== 'none') {
145
+ throw forbidden('FORBIDDEN_ORIGIN', 'write request origin is not allowed')
146
+ }
138
147
  }
139
148
  }
140
- const fetchSite = headers['sec-fetch-site']
141
- if (fetchSite !== undefined && fetchSite !== null && String(fetchSite).trim().length > 0) {
142
- const site = String(fetchSite).trim().toLowerCase()
143
- if (site !== 'same-origin' && site !== 'none') {
144
- throw forbidden('FORBIDDEN_ORIGIN', 'cross-site write requests are not allowed', {
145
- header: 'Sec-Fetch-Site',
146
- })
147
- }
149
+ if (fetchSite.length > 0 && fetchSite !== 'same-origin' && fetchSite !== 'none') {
150
+ throw forbidden('FORBIDDEN_ORIGIN', 'cross-site write requests are not allowed', {
151
+ header: 'Sec-Fetch-Site',
152
+ })
148
153
  }
149
154
  }
150
155