@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.
- package/README.md +1 -1
- package/client.js +428 -406
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -106
- package/src/cortex-ui/dsh/api.ts +5 -2
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/cortex-ui/shared/ipc.ts +3 -4
- package/src/cortex-ui/shared/types.ts +9 -0
- package/src/host/cortex/fs.js +72 -0
- package/src/host/cortex/index.js +49 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/settings.js +54 -0
- package/src/host/cortex/tools.js +2 -0
- package/src/host/cortex/vulns.js +36 -13
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
- 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
|
+
}
|
package/src/host/cortex/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/host/cortex/tools.js
CHANGED
|
@@ -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
|
}
|