@lievclouds/dsh-cortex 0.4.4 → 1.0.0

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.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Host-side directory browser for the export path picker.
3
+ *
4
+ * The DSH desktop WebView cannot expose absolute paths through the browser's
5
+ * File System Access API, so the export dialog asks the Host to list folders and
6
+ * the user picks one. Read-only: no filesystem writes happen here.
7
+ *
8
+ * GET /cortex-api/cortex/fs/list?path=<absolute or ~-prefixed> ->
9
+ * { path, parent, directories: [{ name, path }], home }
10
+ */
11
+
12
+ import { readdirSync, statSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
15
+ import { badRequest, sendJson } from '../http.js'
16
+
17
+ const MAX_PATH_LENGTH = 2000
18
+ const MAX_DIRECTORIES = 500
19
+
20
+ function expandTilde(value) {
21
+ if (!value.startsWith('~')) return value
22
+ return join(homedir(), value.replace(/^~[/\\]?/, ''))
23
+ }
24
+
25
+ function queryPath(searchParams) {
26
+ const raw = searchParams && typeof searchParams.get === 'function' ? searchParams.get('path') : null
27
+ if (raw === null || raw === undefined) return homedir()
28
+ const trimmed = String(raw).trim()
29
+ if (trimmed.length === 0) return homedir()
30
+ if (trimmed.length > MAX_PATH_LENGTH) {
31
+ throw badRequest('path is too long', { field: 'path', max: MAX_PATH_LENGTH })
32
+ }
33
+ const expanded = expandTilde(trimmed)
34
+ if (!isAbsolute(expanded)) {
35
+ throw badRequest('path must be an absolute path', { field: 'path' })
36
+ }
37
+ return resolve(expanded)
38
+ }
39
+
40
+ /** GET /fs/list — list subdirectories of one folder. */
41
+ export function handleList({ res, searchParams }) {
42
+ const target = queryPath(searchParams)
43
+ let stat
44
+ try {
45
+ stat = statSync(target)
46
+ } catch {
47
+ throw badRequest('directory does not exist', { field: 'path' })
48
+ }
49
+ if (!stat.isDirectory()) {
50
+ throw badRequest('path is not a directory', { field: 'path' })
51
+ }
52
+
53
+ let entries
54
+ try {
55
+ entries = readdirSync(target, { withFileTypes: true })
56
+ } catch {
57
+ throw badRequest('cannot read directory', { field: 'path' })
58
+ }
59
+ const directories = entries
60
+ .filter((entry) => entry.isDirectory())
61
+ .map((entry) => ({ name: entry.name, path: join(target, entry.name) }))
62
+ .sort((left, right) => left.name.localeCompare(right.name))
63
+ .slice(0, MAX_DIRECTORIES)
64
+
65
+ const root = parse(target).root
66
+ sendJson(res, 200, {
67
+ path: target,
68
+ parent: target === root ? null : dirname(target),
69
+ directories,
70
+ home: homedir(),
71
+ })
72
+ }
@@ -36,8 +36,11 @@ import {
36
36
  sendJson,
37
37
  } from '../http.js'
38
38
  import * as assets from './assets.js'
39
+ import * as fs from './fs.js'
39
40
  import * as misc from './misc.js'
41
+ import * as settings from './settings.js'
40
42
  import * as repeater from './repeater.js'
43
+ import * as repeaterStore from './repeater-store.js'
41
44
  import * as snapshots from './snapshots.js'
42
45
  import * as vulns from './vulns.js'
43
46
 
@@ -114,6 +117,33 @@ const ROUTES = [
114
117
  { pattern: /^\/poc\/provider-test$/, methods: { POST: misc.unsupportedHandler }, db: false },
115
118
 
116
119
  { pattern: /^\/repeater\/send$/, methods: { POST: repeater.handleSend } },
120
+
121
+ // Single-send history/collections. All state is local JSON under
122
+ // CORTEX_REPEATER_DIR / ~/.cortex/repeater, so these routes must answer even
123
+ // when no compatible database is open (`db: false`).
124
+ {
125
+ pattern: /^\/repeater\/history$/,
126
+ methods: { GET: repeaterStore.handleHistoryList, DELETE: repeaterStore.handleHistoryClear },
127
+ db: false,
128
+ },
129
+ {
130
+ pattern: /^\/repeater\/collections$/,
131
+ methods: {
132
+ GET: repeaterStore.handleCollectionsList,
133
+ POST: repeaterStore.handleCollectionsCreate,
134
+ },
135
+ db: false,
136
+ },
137
+ {
138
+ pattern: /^\/repeater\/collections\/([^/]+)$/,
139
+ methods: { DELETE: repeaterStore.handleCollectionsDelete },
140
+ db: false,
141
+ },
142
+
143
+ // Directory browser for the export-path picker (read-only).
144
+ { pattern: /^\/fs\/list$/, methods: { GET: fs.handleList }, db: false },
145
+ // Remembered export defaults for the export dialog (plugin-local settings).
146
+ { pattern: /^\/export\/settings$/, methods: { GET: settings.handleGetExportSettings }, db: false },
117
147
  ]
118
148
 
119
149
  /* ------------------------------------------------------------------------- */
@@ -224,7 +254,10 @@ async function loadDelegatedHandler(moduleName, exportName) {
224
254
  const candidate =
225
255
  (mod && (mod[exportName] || mod.handle || mod.default)) || null
226
256
  return typeof candidate === 'function' ? candidate : null
227
- } catch {
257
+ } catch (err) {
258
+ // Coarse diagnostics only: never log request data or file contents.
259
+ const kind = err && typeof err.code === 'string' ? err.code : err && err.name ? err.name : 'error'
260
+ console.warn(`[dsh-cortex] delegated module ${moduleName} is unavailable (${String(kind).slice(0, 80)})`)
228
261
  return null
229
262
  }
230
263
  }
@@ -367,6 +400,21 @@ export async function handleCortex(deps) {
367
400
  return
368
401
  }
369
402
 
403
+ // Mini-program (wxapkg) reverse-engineering workbench. Delegated lazily so a
404
+ // failure in the new modules can never take down the rest of Cortex, and so
405
+ // its routes work even without a compatible Cortex data.db (they use their
406
+ // own ~/.cortex/plugin.db).
407
+ if (path === '/miniapps' || path.startsWith('/miniapps/')) {
408
+ const handler = await loadDelegatedHandler('../miniapp/routes', 'handleMiniapps')
409
+ if (!handler) {
410
+ misc.sendUnsupported(res, 'mini-program workbench is not available in this host')
411
+ return
412
+ }
413
+ const handled = await runDelegated(handler, ctx)
414
+ if (!handled) throw notFound('route not found')
415
+ return
416
+ }
417
+
370
418
  /* ----------------------------- route table --------------------------- */
371
419
 
372
420
  const { route, params } = matchRoute(path, method)
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Local Repeater state: single-send history + named request collections.
3
+ *
4
+ * Contract (`.cache/fuzzer-contract.md`):
5
+ * - root = `process.env.CORTEX_REPEATER_DIR || ~/.cortex/repeater`, resolved
6
+ * lazily (never at import time, so tests can point it at a temp dir)
7
+ * - `history.json` / `collections.json`: plain JSON arrays, newest first,
8
+ * max 50 entries each, every write atomic (`.tmp` + rename)
9
+ * - response bodies are stored capped at 256 KiB with a `truncated` flag
10
+ * - no SQLite tables, no user-dir writes outside the resolved root
11
+ *
12
+ * Nothing in this module logs request/response content, cookies or auth
13
+ * headers. Only request/response *shapes* are validated here; the actual
14
+ * outbound path stays `src/host/retest.js`.
15
+ */
16
+
17
+ import { randomUUID } from 'node:crypto'
18
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { dirname, join, resolve } from 'node:path'
21
+ import {
22
+ badRequest,
23
+ queryInt,
24
+ readJsonBody,
25
+ requireBodyString,
26
+ safeDecode,
27
+ sendJson,
28
+ } from '../http.js'
29
+ import {
30
+ ALLOWED_METHODS,
31
+ MAX_HEADERS,
32
+ MAX_HEADER_VALUE,
33
+ MAX_SEND_BODY_BYTES,
34
+ normalizeHeaders,
35
+ } from '../retest.js'
36
+
37
+ /** Max history / collection entries kept on disk (contract). */
38
+ export const MAX_REPEATER_ITEMS = 50
39
+
40
+ /** Response bodies are stored/returned at most 256 KiB (contract). */
41
+ export const MAX_STORED_BODY_BYTES = 256 * 1024
42
+
43
+ const MAX_HISTORY_LIMIT = MAX_REPEATER_ITEMS
44
+ const DEFAULT_HISTORY_LIMIT = 20
45
+ const MAX_URL_LENGTH = 8000
46
+ const MAX_NAME_LENGTH = 80
47
+ const MAX_RESPONSE_HEADERS = MAX_HEADERS * 2
48
+ const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
49
+
50
+ /* ------------------------------------------------------------------------- */
51
+ /* Paths / atomic JSON */
52
+ /* ------------------------------------------------------------------------- */
53
+
54
+ /**
55
+ * Resolve the repeater state root lazily: the environment is read on every
56
+ * call so a test (or an operator) can redirect it before the first write.
57
+ * @returns {string} absolute path
58
+ */
59
+ export function repeaterRoot() {
60
+ const configured = process.env.CORTEX_REPEATER_DIR
61
+ if (typeof configured === 'string' && configured.trim().length > 0) {
62
+ return resolve(configured.trim())
63
+ }
64
+ return join(homedir(), '.cortex', 'repeater')
65
+ }
66
+
67
+ /** @returns {string} */
68
+ export function historyFilePath() {
69
+ return join(repeaterRoot(), 'history.json')
70
+ }
71
+
72
+ /** @returns {string} */
73
+ export function collectionsFilePath() {
74
+ return join(repeaterRoot(), 'collections.json')
75
+ }
76
+
77
+ /**
78
+ * Read a JSON document defensively: missing/corrupt files degrade to `null`.
79
+ * @param {string} filePath
80
+ * @returns {any}
81
+ */
82
+ export function readJsonFile(filePath) {
83
+ try {
84
+ const text = readFileSync(filePath, 'utf8')
85
+ if (text.length === 0) return null
86
+ return JSON.parse(text)
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Write a JSON document atomically: `<file>.tmp` then rename into place.
94
+ * @param {string} filePath
95
+ * @param {unknown} value
96
+ */
97
+ export function writeJsonAtomic(filePath, value) {
98
+ const dir = dirname(filePath)
99
+ mkdirSync(dir, { recursive: true })
100
+ const tempPath = `${filePath}.tmp`
101
+ try {
102
+ writeFileSync(tempPath, JSON.stringify(value, null, 2), 'utf8')
103
+ renameSync(tempPath, filePath)
104
+ } catch (err) {
105
+ try {
106
+ rmSync(tempPath, { force: true })
107
+ } catch {
108
+ /* best-effort cleanup */
109
+ }
110
+ throw err
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Cut a text value to the stored-body byte cap on a UTF-8 boundary.
116
+ * @param {unknown} value
117
+ * @returns {{body: string, truncated: boolean}}
118
+ */
119
+ export function capStoredBody(value) {
120
+ const text = value === null || value === undefined ? '' : String(value)
121
+ const buffer = Buffer.from(text, 'utf8')
122
+ if (buffer.length <= MAX_STORED_BODY_BYTES) return { body: text, truncated: false }
123
+ let end = MAX_STORED_BODY_BYTES
124
+ // Do not cut in the middle of a multi-byte UTF-8 sequence.
125
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1
126
+ return { body: buffer.subarray(0, end).toString('utf8'), truncated: true }
127
+ }
128
+
129
+ /* ------------------------------------------------------------------------- */
130
+ /* History */
131
+ /* ------------------------------------------------------------------------- */
132
+
133
+ /**
134
+ * @param {unknown} value
135
+ * @returns {Record<string, any>|null}
136
+ */
137
+ function plainObject(value) {
138
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
139
+ }
140
+
141
+ /**
142
+ * Read the stored history array (newest first). Corrupt/missing files -> [].
143
+ * @returns {Record<string, any>[]}
144
+ */
145
+ export function loadHistory() {
146
+ return loadArray(historyFilePath())
147
+ }
148
+
149
+ /** @returns {Record<string, any>[]} */
150
+ export function loadCollections() {
151
+ return loadArray(collectionsFilePath())
152
+ }
153
+
154
+ /** @param {string} filePath @returns {Record<string, any>[]} */
155
+ function loadArray(filePath) {
156
+ const parsed = readJsonFile(filePath)
157
+ const raw = Array.isArray(parsed)
158
+ ? parsed
159
+ : parsed && Array.isArray(parsed.items)
160
+ ? parsed.items
161
+ : []
162
+ return raw.filter((item) => plainObject(item) !== null)
163
+ }
164
+
165
+ /** @param {string} filePath @param {unknown[]} items */
166
+ function saveArray(filePath, items) {
167
+ writeJsonAtomic(filePath, items.slice(0, MAX_REPEATER_ITEMS))
168
+ }
169
+
170
+ /**
171
+ * Normalize one reusable request object (shared `RepeaterSendInput` shape).
172
+ * Header rules are delegated to `normalizeHeaders`, so this never re-implements
173
+ * the protocol-header / value validation from `retest.js`.
174
+ * @param {unknown} value
175
+ * @param {string} field
176
+ */
177
+ export function normalizeStoredRequest(value, field = 'request') {
178
+ const source = plainObject(value)
179
+ if (!source) throw badRequest(`${field} must be an object`, { field })
180
+ const method = source.method
181
+ if (typeof method !== 'string' || !ALLOWED_METHODS.includes(method)) {
182
+ throw badRequest(`${field}.method must be an uppercase HTTP method`, { field: `${field}.method` })
183
+ }
184
+ const url = source.url
185
+ if (typeof url !== 'string' || url.length === 0) {
186
+ throw badRequest(`${field}.url is required`, { field: `${field}.url` })
187
+ }
188
+ if (url.length > MAX_URL_LENGTH) {
189
+ throw badRequest(`${field}.url is too long`, { field: `${field}.url`, max: MAX_URL_LENGTH })
190
+ }
191
+ const headers = normalizeHeaders(source.headers)
192
+ let body = ''
193
+ if (source.body !== undefined && source.body !== null) {
194
+ if (typeof source.body !== 'string') {
195
+ throw badRequest(`${field}.body must be a string`, { field: `${field}.body` })
196
+ }
197
+ if (Buffer.byteLength(source.body, 'utf8') > MAX_SEND_BODY_BYTES) {
198
+ throw badRequest(`${field}.body is too large`, {
199
+ field: `${field}.body`,
200
+ maxBytes: MAX_SEND_BODY_BYTES,
201
+ })
202
+ }
203
+ body = source.body
204
+ }
205
+ return { method, url, headers, body }
206
+ }
207
+
208
+ /**
209
+ * Normalize one response object (shared `RepeaterSendResult.response` shape).
210
+ * The body is capped at 256 KiB and the `truncated` flag reflects that cut.
211
+ * @param {unknown} value
212
+ * @param {string} field
213
+ */
214
+ export function normalizeStoredResponse(value, field = 'response') {
215
+ const source = plainObject(value)
216
+ if (!source) throw badRequest(`${field} must be an object`, { field })
217
+ const status = source.status
218
+ if (!Number.isInteger(status) || status < 0 || status > 999) {
219
+ throw badRequest(`${field}.status must be an integer status code`, { field: `${field}.status` })
220
+ }
221
+ const headersSource = source.headers === undefined ? {} : source.headers
222
+ const headersObject = plainObject(headersSource)
223
+ if (!headersObject) throw badRequest(`${field}.headers must be an object`, { field: `${field}.headers` })
224
+ const entries = Object.entries(headersObject)
225
+ if (entries.length > MAX_RESPONSE_HEADERS) {
226
+ throw badRequest(`${field}.headers has too many entries`, {
227
+ field: `${field}.headers`,
228
+ max: MAX_RESPONSE_HEADERS,
229
+ })
230
+ }
231
+ /** @type {Record<string, string>} */
232
+ const headers = {}
233
+ for (const [name, headerValue] of entries) {
234
+ if (!HEADER_NAME_RE.test(name)) {
235
+ throw badRequest(`${field}.headers has an invalid name`, { field: `${field}.headers` })
236
+ }
237
+ if (typeof headerValue !== 'string' || headerValue.length > MAX_HEADER_VALUE) {
238
+ throw badRequest(`${field}.headers has an invalid value`, { field: `${field}.headers` })
239
+ }
240
+ headers[name] = headerValue
241
+ }
242
+ if (typeof source.body !== 'string') {
243
+ throw badRequest(`${field}.body must be a string`, { field: `${field}.body` })
244
+ }
245
+ const durationMs = source.durationMs
246
+ if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs < 0) {
247
+ throw badRequest(`${field}.durationMs must be a non-negative number`, {
248
+ field: `${field}.durationMs`,
249
+ })
250
+ }
251
+ const declaredTruncated = source.truncated
252
+ if (declaredTruncated !== undefined && typeof declaredTruncated !== 'boolean') {
253
+ throw badRequest(`${field}.truncated must be a boolean`, { field: `${field}.truncated` })
254
+ }
255
+ const capped = capStoredBody(source.body)
256
+ return {
257
+ status,
258
+ headers,
259
+ body: capped.body,
260
+ durationMs,
261
+ truncated: declaredTruncated === true || capped.truncated,
262
+ }
263
+ }
264
+
265
+ /** Build a display-only raw HTTP request text from the request shape. */
266
+ function rawRequestText(request) {
267
+ let target = String(request.url || '')
268
+ try {
269
+ const url = new URL(target)
270
+ target = `${url.pathname}${url.search}`
271
+ } catch {
272
+ /* keep the verbatim value */
273
+ }
274
+ const lines = [`${request.method} ${target} HTTP/1.1`]
275
+ for (const [name, value] of Object.entries(request.headers || {})) {
276
+ lines.push(`${name}: ${value}`)
277
+ }
278
+ const body = typeof request.body === 'string' ? request.body : ''
279
+ return `${lines.join('\r\n')}\r\n\r\n${body}`
280
+ }
281
+
282
+ /**
283
+ * Append a history entry after a successful single Repeater send.
284
+ *
285
+ * Best-effort by design: any disk failure returns `null` instead of throwing,
286
+ * so a history write can never fail the actual send. Request/response bodies
287
+ * are stored (capped), but this function never logs them.
288
+ *
289
+ * @param {{vulnId?: string, request: Record<string, any>, result: Record<string, any>}} input
290
+ * @returns {Record<string, any>|null}
291
+ */
292
+ export function recordSendHistory(input) {
293
+ try {
294
+ const source = input && typeof input === 'object' ? input : {}
295
+ const request = source.request || {}
296
+ const result = source.result || {}
297
+ const response = normalizeStoredResponse({
298
+ status: Number(result.status) || 0,
299
+ headers: plainObject(result.headers) || {},
300
+ body: typeof result.body === 'string' ? result.body : '',
301
+ durationMs: Number(result.durationMs) || 0,
302
+ truncated: result.bodyTruncated === true,
303
+ })
304
+ const normalizedRequest = {
305
+ method: String(request.method || ''),
306
+ url: request.url instanceof URL ? request.url.href : String(request.url || ''),
307
+ headers: plainObject(request.headers) || {},
308
+ body: request.body === undefined || request.body === null ? '' : String(request.body),
309
+ }
310
+ const entry = {
311
+ id: randomUUID(),
312
+ createdAt: new Date().toISOString(),
313
+ vulnId: typeof source.vulnId === 'string' ? source.vulnId : '',
314
+ raw: rawRequestText(normalizedRequest),
315
+ request: normalizedRequest,
316
+ response,
317
+ }
318
+ const items = loadHistory()
319
+ items.unshift(entry)
320
+ saveArray(historyFilePath(), items)
321
+ return entry
322
+ } catch {
323
+ return null
324
+ }
325
+ }
326
+
327
+ /* ------------------------------------------------------------------------- */
328
+ /* Handlers — history */
329
+ /* ------------------------------------------------------------------------- */
330
+
331
+ /** GET /repeater/history?limit=20 -> { items } */
332
+ export function handleHistoryList({ res, searchParams }) {
333
+ const limit =
334
+ queryInt(searchParams, 'limit', { min: 1, max: MAX_HISTORY_LIMIT }) ?? DEFAULT_HISTORY_LIMIT
335
+ const items = loadHistory().slice(0, limit)
336
+ sendJson(res, 200, { items })
337
+ }
338
+
339
+ /** DELETE /repeater/history -> { cleared: true } */
340
+ export function handleHistoryClear({ res }) {
341
+ saveArray(historyFilePath(), [])
342
+ sendJson(res, 200, { cleared: true })
343
+ }
344
+
345
+ /* ------------------------------------------------------------------------- */
346
+ /* Handlers — collections */
347
+ /* ------------------------------------------------------------------------- */
348
+
349
+ /** GET /repeater/collections -> { items } */
350
+ export function handleCollectionsList({ res }) {
351
+ sendJson(res, 200, { items: loadCollections() })
352
+ }
353
+
354
+ /**
355
+ * POST /repeater/collections
356
+ * Body: `{name, vulnId, raw, request, response?}` -> `{ item }`
357
+ */
358
+ export async function handleCollectionsCreate({ req, res }) {
359
+ const body = await readJsonBody(req)
360
+ // Contract: the *trimmed* name is 1..80 characters.
361
+ const rawName = requireBodyString(body, 'name', { max: 200 })
362
+ const name = rawName.trim()
363
+ if (name.length === 0) {
364
+ throw badRequest('name must not be empty', { field: 'name' })
365
+ }
366
+ if (name.length > MAX_NAME_LENGTH) {
367
+ throw badRequest('name is too long', { field: 'name', max: MAX_NAME_LENGTH })
368
+ }
369
+ let vulnId = ''
370
+ if (body.vulnId !== undefined && body.vulnId !== null) {
371
+ vulnId = requireBodyString(body, 'vulnId', { max: 200 })
372
+ }
373
+ let raw = ''
374
+ if (body.raw !== undefined && body.raw !== null) {
375
+ if (typeof body.raw !== 'string') throw badRequest('raw must be a string', { field: 'raw' })
376
+ raw = body.raw
377
+ }
378
+ const request = normalizeStoredRequest(body.request, 'request')
379
+ let response = null
380
+ if (body.response !== undefined && body.response !== null) {
381
+ response = normalizeStoredResponse(body.response, 'response')
382
+ }
383
+
384
+ const items = loadCollections()
385
+ if (items.length >= MAX_REPEATER_ITEMS) {
386
+ throw badRequest('collection is full', { field: 'name', max: MAX_REPEATER_ITEMS })
387
+ }
388
+ const item = {
389
+ id: randomUUID(),
390
+ name,
391
+ createdAt: new Date().toISOString(),
392
+ vulnId,
393
+ raw,
394
+ request,
395
+ response,
396
+ }
397
+ items.unshift(item)
398
+ saveArray(collectionsFilePath(), items)
399
+ sendJson(res, 200, { item })
400
+ }
401
+
402
+ /** DELETE /repeater/collections/:id -> { deleted: boolean } */
403
+ export function handleCollectionsDelete({ res, params }) {
404
+ const id = safeDecode(params[0], 'collection id')
405
+ if (id.length === 0) throw badRequest('collection id is required')
406
+ const items = loadCollections()
407
+ const next = items.filter((item) => item.id !== id)
408
+ const deleted = next.length !== items.length
409
+ if (deleted) saveArray(collectionsFilePath(), next)
410
+ sendJson(res, 200, { deleted })
411
+ }
@@ -10,11 +10,14 @@
10
10
  * result onto the exact shared `RepeaterSendResult` shape.
11
11
  *
12
12
  * The renderer's RepeaterSendInput uses camelCase; the shared schema accepts
13
- * that. Request headers/bodies are never logged.
13
+ * that. Request headers/bodies are never logged. A successful send also
14
+ * appends one best-effort entry to the local Repeater history (single-send
15
+ * convenience; a disk failure never changes the response).
14
16
  */
15
17
 
16
18
  import { ApiError, readJsonBody, sendJson } from '../http.js'
17
19
  import { sendRetest, validateSendRequest } from '../retest.js'
20
+ import { recordSendHistory } from './repeater-store.js'
18
21
 
19
22
  /**
20
23
  * @param {{req: import('node:http').IncomingMessage,
@@ -32,7 +35,7 @@ export async function handleSend({ req, res, config, repository }) {
32
35
  const context = repository.getRetestContext(request.vulnId)
33
36
  const result = await sendRetest({ request, context })
34
37
 
35
- sendJson(res, 200, {
38
+ const responsePayload = {
36
39
  request: {
37
40
  method: request.method,
38
41
  url: request.url instanceof URL ? request.url.href : String(request.url),
@@ -46,5 +49,15 @@ export async function handleSend({ req, res, config, repository }) {
46
49
  durationMs: result.durationMs,
47
50
  truncated: result.bodyTruncated === true,
48
51
  },
52
+ }
53
+
54
+ // Best-effort local history (contract): a disk failure must never fail the
55
+ // send, and the response shape returned to the renderer stays unchanged.
56
+ // Respond first so the renderer never waits for the history write.
57
+ sendJson(res, 200, responsePayload)
58
+ recordSendHistory({
59
+ vulnId: request.vulnId,
60
+ request: responsePayload.request,
61
+ result,
49
62
  })
50
63
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Small plugin-local settings store (JSON file under ~/.cortex).
3
+ *
4
+ * Currently remembers the last successful export directory so the export dialog
5
+ * can default to it on the next export. Kept out of Cortex's own tables/config
6
+ * so the desktop app and schema stay untouched.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join } from 'node:path'
12
+ import { sendJson } from '../http.js'
13
+
14
+ function settingsPath() {
15
+ return process.env.CORTEX_SETTINGS_FILE || join(homedir(), '.cortex', 'plugin-settings.json')
16
+ }
17
+
18
+ function readSettings() {
19
+ const file = settingsPath()
20
+ if (!existsSync(file)) return {}
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
23
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
24
+ } catch {
25
+ return {}
26
+ }
27
+ }
28
+
29
+ function writeSettings(settings) {
30
+ const file = settingsPath()
31
+ mkdirSync(dirname(file), { recursive: true })
32
+ writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
33
+ }
34
+
35
+ export function getLastExportDir() {
36
+ const value = readSettings().exportDir
37
+ return typeof value === 'string' && value.trim().length > 0 ? value : null
38
+ }
39
+
40
+ /** Best-effort: a settings write failure must never fail an export. */
41
+ export function rememberExportDir(outputDir) {
42
+ try {
43
+ const settings = readSettings()
44
+ settings.exportDir = outputDir
45
+ writeSettings(settings)
46
+ } catch {
47
+ /* ignore */
48
+ }
49
+ }
50
+
51
+ /** GET /export/settings -> { outputDir: string | null } */
52
+ export function handleGetExportSettings({ res }) {
53
+ sendJson(res, 200, { outputDir: getLastExportDir() })
54
+ }
@@ -14,6 +14,7 @@ import {
14
14
  normalizeTestParameters,
15
15
  } from './normalize-asset.js'
16
16
  import { auditCompleteness, syncInternalScope } from './data-tools.js'
17
+ import { createMiniappToolDefinitions } from '../miniapp/tools.js'
17
18
 
18
19
  const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info']
19
20
  const STATUSES = ['Confirmed', 'Inconclusive', 'Retest_Passed']
@@ -981,6 +982,7 @@ export function createCortexToolDefinitions({ holder, config }) {
981
982
  graphSummary,
982
983
  auditTool,
983
984
  internalSyncTool,
985
+ ...createMiniappToolDefinitions({ holder, config }),
984
986
  ]
985
987
  return readOnly ? tools.map((tool) => ({ ...tool, description: `${tool.description} (current host: read-only)` })) : tools
986
988
  }