@gpzhang2001/sharpkit-proxy 0.2.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.
- package/LICENSE +201 -0
- package/README.md +27 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +110 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1307 -0
- package/lib/index.js.map +1 -0
- package/package.json +47 -0
- package/src/client.ts +436 -0
- package/src/index.ts +598 -0
- package/src/replay.ts +228 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caido proxy tools — TS port of strix tools/proxy/tools.py (schemas and
|
|
3
|
+
* output shaping verbatim; the client half lives in client.ts, replay pure
|
|
4
|
+
* helpers in replay.ts). Five tools: list_requests / view_request /
|
|
5
|
+
* repeat_request / list_sitemap / view_sitemap_entry. The client resolves
|
|
6
|
+
* lazily from the sandbox session's Caido bootstrap (concurrent with scan
|
|
7
|
+
* start; the first tool call pays the login wait — strix lazy parity).
|
|
8
|
+
* @module @gpzhang2001/sharpkit-proxy
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import type Schema from '@deepseek-ai/schemastery'
|
|
13
|
+
import z from '@deepseek-ai/schemastery'
|
|
14
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { CaidoClient, type SortBy } from './client.ts'
|
|
16
|
+
|
|
17
|
+
/** Deployment-tunable configuration. */
|
|
18
|
+
export interface Config {
|
|
19
|
+
/** Sandbox session cache key shared with the other tool packages. */
|
|
20
|
+
readonly scanId?: string
|
|
21
|
+
/** list_requests page size default (strix first=50). */
|
|
22
|
+
readonly listPageSize?: number
|
|
23
|
+
/** Replay dispatch deadline (strix 30s). */
|
|
24
|
+
readonly replayTimeoutMs?: number
|
|
25
|
+
/** Client-level per-call timeout (strix @function_tool timeout=60/120). */
|
|
26
|
+
readonly caidoTimeoutMs?: number
|
|
27
|
+
/** Ask-approval gate on repeat_request (fail-closed without an approval service). */
|
|
28
|
+
readonly requireApproval?: boolean
|
|
29
|
+
/** Authorized target hosts/values; repeat_request refuses hosts outside this list (exact or dot-suffix subdomain). */
|
|
30
|
+
readonly authorizedTargets?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const name = 'pentest-tool-proxy'
|
|
34
|
+
|
|
35
|
+
export const inject = ['tools', 'pentestSandbox']
|
|
36
|
+
|
|
37
|
+
export const Config: Schema<Config> = z.object({
|
|
38
|
+
scanId: z.string().default('pentest'),
|
|
39
|
+
listPageSize: z.number().default(50),
|
|
40
|
+
replayTimeoutMs: z.number().default(30_000),
|
|
41
|
+
caidoTimeoutMs: z.number().default(60_000),
|
|
42
|
+
requireApproval: z.boolean().default(true),
|
|
43
|
+
authorizedTargets: z.array(z.string()),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a repeat target host is inside the authorized list. Entries may be
|
|
48
|
+
* bare hosts (`example.com`), wildcard subdomains (`*.example.com`), or full
|
|
49
|
+
* URLs (hostname taken); a candidate matches on exact host or a dot-suffix
|
|
50
|
+
* subdomain. An empty list allows everything (fail-open; warned once).
|
|
51
|
+
* @param host - the replay target hostname.
|
|
52
|
+
* @param authorized - the configured target entries.
|
|
53
|
+
*/
|
|
54
|
+
export function targetHostAllowed(host: string, authorized: readonly string[]): boolean {
|
|
55
|
+
if (authorized.length === 0) return true
|
|
56
|
+
const candidate = host.toLowerCase()
|
|
57
|
+
return authorized.some(entry => {
|
|
58
|
+
let allowed = entry.trim().toLowerCase()
|
|
59
|
+
if (allowed === '') return false
|
|
60
|
+
if (allowed.includes('://')) {
|
|
61
|
+
try {
|
|
62
|
+
allowed = new URL(allowed).hostname
|
|
63
|
+
} catch {
|
|
64
|
+
// keep the raw entry
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
allowed = allowed.replace(/^\*\./, '').replace(/\/.*$/, '')
|
|
68
|
+
return allowed !== '' && (candidate === allowed || candidate.endsWith(`.${allowed}`))
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the replay target hostname: an explicit url modification wins;
|
|
74
|
+
* otherwise the stored request's host.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveRepeatTargetHost(storedHost: string, modifications: Readonly<Record<string, unknown>> | undefined): string {
|
|
77
|
+
const override = modifications?.['url']
|
|
78
|
+
if (typeof override === 'string' && override !== '') {
|
|
79
|
+
try {
|
|
80
|
+
return new URL(override).hostname
|
|
81
|
+
} catch {
|
|
82
|
+
// fall through to the stored host
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return storedHost
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Sitemap page size (strix parity: 30 entries per page). */
|
|
89
|
+
export const SITEMAP_PAGE_SIZE = 30
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Slice one sitemap connection payload to a 1-indexed page of edges (the
|
|
93
|
+
* total count is preserved). Payloads without a recognized connection pass
|
|
94
|
+
* through untouched.
|
|
95
|
+
*/
|
|
96
|
+
export function pageSitemapPayload(payload: Record<string, unknown>, page: number): Record<string, unknown> {
|
|
97
|
+
for (const key of ['sitemapRootEntries', 'sitemapDescendantEntries']) {
|
|
98
|
+
const connection = payload[key]
|
|
99
|
+
if (typeof connection !== 'object' || connection === null) continue
|
|
100
|
+
const edges = (connection as { edges?: unknown }).edges
|
|
101
|
+
if (!Array.isArray(edges)) continue
|
|
102
|
+
const start = Math.max(0, (page - 1) * SITEMAP_PAGE_SIZE)
|
|
103
|
+
const sliced = edges.slice(start, start + SITEMAP_PAGE_SIZE)
|
|
104
|
+
return { ...payload, [key]: { ...(connection as Record<string, unknown>), edges: sliced, page, page_size: SITEMAP_PAGE_SIZE, has_more: start + SITEMAP_PAGE_SIZE < edges.length } }
|
|
105
|
+
}
|
|
106
|
+
return payload
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** strix view_request search hits: ≤20 matches with ±40 chars of context. */
|
|
110
|
+
interface SearchHit {
|
|
111
|
+
readonly match: string
|
|
112
|
+
readonly position: number
|
|
113
|
+
readonly before: string
|
|
114
|
+
readonly after: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compact regex hits over raw content (strix `_format_search_hits`).
|
|
119
|
+
* @param content - the raw text.
|
|
120
|
+
* @param pattern - the model-supplied regex.
|
|
121
|
+
* @returns the hits payload, or an error entry when the regex is invalid.
|
|
122
|
+
*/
|
|
123
|
+
export function formatSearchHits(content: string, pattern: string): { readonly hits: SearchHit[]; readonly totalHits: number; readonly error?: string } {
|
|
124
|
+
let regex: RegExp
|
|
125
|
+
try {
|
|
126
|
+
regex = new RegExp(pattern, 'g')
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { hits: [], totalHits: 0, error: `Invalid regex: ${String(error)}` }
|
|
129
|
+
}
|
|
130
|
+
const hits: SearchHit[] = []
|
|
131
|
+
for (const match of content.matchAll(regex)) {
|
|
132
|
+
const start = match.index ?? 0
|
|
133
|
+
const end = start + match[0].length
|
|
134
|
+
hits.push({ match: match[0], position: start, before: content.slice(Math.max(0, start - 40), start), after: content.slice(end, end + 40) })
|
|
135
|
+
if (hits.length >= 20) break
|
|
136
|
+
}
|
|
137
|
+
return { hits, totalHits: hits.length }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Line-paginated raw content page (strix `_format_text_page`).
|
|
142
|
+
* @param content - the raw text.
|
|
143
|
+
* @param page - 1-indexed page number.
|
|
144
|
+
* @param pageSize - lines per page.
|
|
145
|
+
*/
|
|
146
|
+
export function formatTextPage(content: string, page: number, pageSize: number): {
|
|
147
|
+
readonly content: string
|
|
148
|
+
readonly page: number
|
|
149
|
+
readonly pageSize: number
|
|
150
|
+
readonly totalLines: number
|
|
151
|
+
readonly hasMore: boolean
|
|
152
|
+
} {
|
|
153
|
+
const lines = content.split('\n')
|
|
154
|
+
const start = Math.max(0, (page - 1) * pageSize)
|
|
155
|
+
const end = start + pageSize
|
|
156
|
+
return { content: lines.slice(start, end).join('\n'), page, pageSize, totalLines: lines.length, hasMore: end < lines.length }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Strip undefined values so an object satisfies the JsonValue index contract. */
|
|
160
|
+
function structuredToPlain(value: Record<string, unknown>): { [key: string]: JsonValueLike } {
|
|
161
|
+
const plain: { [key: string]: JsonValueLike } = JSON.parse(JSON.stringify(value)) as { [key: string]: JsonValueLike }
|
|
162
|
+
return plain
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Local structural twin of the tool-runtime JsonValue (type-only import). */
|
|
166
|
+
type JsonValueLike = string | number | boolean | null | JsonValueLike[] | { [key: string]: JsonValueLike }
|
|
167
|
+
|
|
168
|
+
interface ToolRunContextLike {
|
|
169
|
+
readonly signal: AbortSignal
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Unwrap the GraphQL envelope for one scope row. */
|
|
173
|
+
function plainScope(envelope: unknown): Record<string, unknown> {
|
|
174
|
+
const record = envelope as Record<string, unknown>
|
|
175
|
+
for (const key of ['scope', 'createScope', 'updateScope']) {
|
|
176
|
+
const inner = record[key]
|
|
177
|
+
if (typeof inner === 'object' && inner !== null) {
|
|
178
|
+
const scope = (inner as Record<string, unknown>)['scope']
|
|
179
|
+
if (typeof scope === 'object' && scope !== null) return scope as Record<string, unknown>
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return record
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Unwrap a scopes list envelope. */
|
|
186
|
+
function plainScopes(rows: unknown[]): Record<string, unknown>[] {
|
|
187
|
+
return rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Canonical value of view_request (oneOf the two modes + error). */
|
|
191
|
+
type ViewRequestValue = {
|
|
192
|
+
readonly kind: 'hits'
|
|
193
|
+
readonly hits: SearchHit[]
|
|
194
|
+
readonly total_hits: number
|
|
195
|
+
} | {
|
|
196
|
+
readonly kind: 'page'
|
|
197
|
+
readonly content: string
|
|
198
|
+
readonly page: number
|
|
199
|
+
readonly page_size: number
|
|
200
|
+
readonly total_lines: number
|
|
201
|
+
readonly has_more: boolean
|
|
202
|
+
} | {
|
|
203
|
+
readonly kind: 'error'
|
|
204
|
+
readonly error: string
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
208
|
+
const scanId = config.scanId ?? 'pentest'
|
|
209
|
+
const listPageSize = config.listPageSize ?? 50
|
|
210
|
+
const replayTimeoutMs = config.replayTimeoutMs ?? 30_000
|
|
211
|
+
const caidoTimeoutMs = config.caidoTimeoutMs ?? 60_000
|
|
212
|
+
let clientPromise: Promise<CaidoClient> | undefined
|
|
213
|
+
let warnedNoAllowlist = false
|
|
214
|
+
|
|
215
|
+
/** Authorized targets: Config first, else the preset's scan targets. Empty = fail-open (warned once). */
|
|
216
|
+
const authorizedTargets = (): string[] => {
|
|
217
|
+
if (config.authorizedTargets !== undefined) return config.authorizedTargets
|
|
218
|
+
const preset = ctx.get('pentestPreset') as { authorizedTargets?: Array<{ value: string }> } | undefined
|
|
219
|
+
const values = preset?.authorizedTargets?.map(target => target.value) ?? []
|
|
220
|
+
if (values.length === 0 && !warnedNoAllowlist) {
|
|
221
|
+
warnedNoAllowlist = true
|
|
222
|
+
ctx.logger.warn('pentest-proxy: no authorizedTargets configured; repeat_request target enforcement is inactive (fail-open)')
|
|
223
|
+
}
|
|
224
|
+
return values
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Lazy client: resolves the session's Caido bootstrap on first use (strix parity). */
|
|
228
|
+
const client = (signal: AbortSignal): Promise<CaidoClient> => {
|
|
229
|
+
// A failed bootstrap stays failed (strix caido_handle parity: every caller
|
|
230
|
+
// sees the same degraded state); the wording is stable so the model gets
|
|
231
|
+
// consistent guidance instead of the raw exception.
|
|
232
|
+
clientPromise ??= (async () => {
|
|
233
|
+
const session = await ctx.pentestSandbox.createSession({ scanId })
|
|
234
|
+
const endpoint = await session.caidoEndpoint()
|
|
235
|
+
return new CaidoClient({ baseUrl: endpoint.baseUrl, token: endpoint.token })
|
|
236
|
+
})().catch(error => {
|
|
237
|
+
throw new Error(`Caido client not available in run context: ${String(error instanceof Error ? error.message : error)}`)
|
|
238
|
+
})
|
|
239
|
+
const settled = clientPromise
|
|
240
|
+
if (settled === undefined) throw new Error('unreachable: client promise just assigned')
|
|
241
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
242
|
+
signal.addEventListener('abort', () => reject(new Error('caido call aborted')), { once: true })
|
|
243
|
+
const timer = setTimeout(() => reject(new Error(`caido call timed out after ${String(caidoTimeoutMs)}ms`)), caidoTimeoutMs)
|
|
244
|
+
void settled.then(() => clearTimeout(timer), () => clearTimeout(timer))
|
|
245
|
+
})
|
|
246
|
+
return Promise.race([settled, timeout])
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (config.requireApproval !== false) {
|
|
250
|
+
void ctx.on('tools/pre-execute', async (exec, next) => {
|
|
251
|
+
if (exec.name === 'repeat_request') return { kind: 'ask' as const, reason: 'pentest: replay a captured request against the target' }
|
|
252
|
+
return next()
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
ctx.tools.register(defineTool({
|
|
257
|
+
name: 'list_requests',
|
|
258
|
+
description: `List captured HTTP requests from the Caido proxy with HTTPQL filtering. HTTPQL: integer fields (resp.code, req.port, id, roundtrip) use eq/gt/gte/lt/lte/ne e.g. 'resp.code.gte:400'; text fields (req.method, req.host, req.path, req.query, req.ext, req.raw) use regex/cont/eq e.g. 'req.path.cont:"/api/"'; dates use gt/lt with ISO e.g. 'req.created_at.gt:"2024-01-01T00:00:00Z"'; combine with AND/OR; no NOT — use ne/ncont/nregex. String values MUST be quoted, integers MUST NOT. A bare quoted string searches req.raw and resp.raw. Pagination: pass page_info.end_cursor as after.`,
|
|
259
|
+
parameters: {
|
|
260
|
+
httpql_filter: { type: 'string', description: 'Caido HTTPQL query (optional).' },
|
|
261
|
+
first: { type: 'integer', description: `Entries per page (default ${String(listPageSize)}).` },
|
|
262
|
+
after: { type: 'string', description: "Cursor from a previous response's page_info.end_cursor." },
|
|
263
|
+
sort_by: { type: 'string', enum: ['timestamp', 'host', 'method', 'path', 'status_code', 'response_time', 'response_size', 'source'], description: 'Sort key (default timestamp).' },
|
|
264
|
+
sort_order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort order (default desc).' },
|
|
265
|
+
scope_id: { type: 'string', description: 'Restrict to a Caido scope.' },
|
|
266
|
+
},
|
|
267
|
+
output: {
|
|
268
|
+
schema: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties: {
|
|
271
|
+
success: { type: 'boolean', required: true },
|
|
272
|
+
entries: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
|
|
273
|
+
page_info: { type: 'object', properties: {}, additionalProperties: true },
|
|
274
|
+
error: { type: 'string' },
|
|
275
|
+
},
|
|
276
|
+
additionalProperties: false,
|
|
277
|
+
},
|
|
278
|
+
render: (_args, value) => {
|
|
279
|
+
const result = value as { success: boolean; entries: unknown[]; error?: string }
|
|
280
|
+
if (!result.success) return [{ type: 'text', text: `list_requests failed: ${result.error ?? 'unknown'}` }]
|
|
281
|
+
return [{ type: 'text', text: `${String(result.entries.length)} entries (cursor pagination via page_info)` }]
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
execute: async (args, exec) => {
|
|
285
|
+
try {
|
|
286
|
+
const caido = await client(exec.signal)
|
|
287
|
+
const connection = await caido.listRequests({
|
|
288
|
+
httpqlFilter: args.httpql_filter,
|
|
289
|
+
first: args.first ?? listPageSize,
|
|
290
|
+
after: args.after,
|
|
291
|
+
sortBy: args.sort_by as SortBy | undefined,
|
|
292
|
+
sortOrder: args.sort_order,
|
|
293
|
+
scopeId: args.scope_id,
|
|
294
|
+
}, exec.signal)
|
|
295
|
+
return {
|
|
296
|
+
success: true,
|
|
297
|
+
entries: connection.entries.map(entry => ({
|
|
298
|
+
cursor: entry.cursor,
|
|
299
|
+
request: {
|
|
300
|
+
id: entry.request.id,
|
|
301
|
+
host: entry.request.host,
|
|
302
|
+
port: entry.request.port,
|
|
303
|
+
method: entry.request.method,
|
|
304
|
+
path: entry.request.path,
|
|
305
|
+
query: entry.request.query,
|
|
306
|
+
is_tls: entry.request.tls,
|
|
307
|
+
created_at: entry.request.createdAt,
|
|
308
|
+
},
|
|
309
|
+
response: entry.response === null ? null : {
|
|
310
|
+
id: entry.response.id,
|
|
311
|
+
status_code: entry.response.statusCode,
|
|
312
|
+
length: entry.response.length,
|
|
313
|
+
created_at: entry.response.createdAt,
|
|
314
|
+
},
|
|
315
|
+
})),
|
|
316
|
+
page_info: {
|
|
317
|
+
has_next_page: connection.pageInfo.hasNextPage,
|
|
318
|
+
has_previous_page: connection.pageInfo.hasPreviousPage,
|
|
319
|
+
start_cursor: connection.pageInfo.startCursor,
|
|
320
|
+
end_cursor: connection.pageInfo.endCursor,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return { success: false, entries: [], page_info: {}, error: String(error instanceof Error ? error.message : error) }
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
}))
|
|
328
|
+
|
|
329
|
+
ctx.tools.register(defineTool({
|
|
330
|
+
name: 'view_request',
|
|
331
|
+
description: `View a captured request or its response raw content, optionally regex-searched. With search_pattern: up to 20 compact hits with before/after context (hunting reflections, leaked URLs, hidden parameters). Without: full content line-paginated (page, page_size). Patterns like '/api/[a-zA-Z0-9._/-]+' (endpoints), 'https?://[^\\s<>"]+' (URLs), '[?&][a-zA-Z0-9_]+=([^&\\s]+)' (query params).`,
|
|
332
|
+
parameters: {
|
|
333
|
+
request_id: { type: 'string', required: true, description: 'Request ID from list_requests.' },
|
|
334
|
+
part: { type: 'string', enum: ['request', 'response'], description: 'Which raw half to view (default request).' },
|
|
335
|
+
search_pattern: { type: 'string', description: 'Optional regex; switches to compact hits mode.' },
|
|
336
|
+
page: { type: 'integer', description: '1-indexed page (only without search_pattern).' },
|
|
337
|
+
page_size: { type: 'integer', description: 'Lines per page (default 50).' },
|
|
338
|
+
},
|
|
339
|
+
output: {
|
|
340
|
+
schema: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
properties: {
|
|
343
|
+
kind: { type: 'string', required: true, enum: ['hits', 'page', 'error'] },
|
|
344
|
+
hits: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
|
|
345
|
+
total_hits: { type: 'integer' },
|
|
346
|
+
content: { type: 'string' },
|
|
347
|
+
page: { type: 'integer' },
|
|
348
|
+
page_size: { type: 'integer' },
|
|
349
|
+
total_lines: { type: 'integer' },
|
|
350
|
+
has_more: { type: 'boolean' },
|
|
351
|
+
error: { type: 'string' },
|
|
352
|
+
},
|
|
353
|
+
additionalProperties: false,
|
|
354
|
+
},
|
|
355
|
+
render: (_args, value) => {
|
|
356
|
+
const result = value as ViewRequestValue
|
|
357
|
+
if (result.kind === 'error') return [{ type: 'text', text: `view_request failed: ${result.error}` }]
|
|
358
|
+
if (result.kind === 'hits') return [{ type: 'text', text: `${String(result.total_hits)} regex hit(s)` }]
|
|
359
|
+
return [{ type: 'text', text: `page ${String(result.page)}/${String(Math.ceil(result.total_lines / Math.max(1, result.page_size)))} of ${String(result.total_lines)} lines` }]
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
execute: async (args, exec) => {
|
|
363
|
+
try {
|
|
364
|
+
const caido = await client(exec.signal)
|
|
365
|
+
const stored = await caido.getRequest(args.request_id, exec.signal)
|
|
366
|
+
if (stored === null) return { kind: 'error' as const, error: `Request ${args.request_id} not found` }
|
|
367
|
+
const raw = args.part === 'response' ? stored.responseRaw : stored.requestRaw
|
|
368
|
+
if (raw === null) return { kind: 'error' as const, error: `No raw ${args.part === 'response' ? 'response' : 'request'} for ${args.request_id}` }
|
|
369
|
+
if (args.search_pattern !== undefined && args.search_pattern !== '') {
|
|
370
|
+
const hits = formatSearchHits(raw, args.search_pattern)
|
|
371
|
+
if (hits.error !== undefined) return { kind: 'error' as const, error: hits.error }
|
|
372
|
+
return {
|
|
373
|
+
kind: 'hits' as const,
|
|
374
|
+
hits: hits.hits.map(hit => ({ match: hit.match, position: hit.position, before: hit.before, after: hit.after })),
|
|
375
|
+
total_hits: hits.totalHits,
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const textPage = formatTextPage(raw, args.page ?? 1, args.page_size ?? 50)
|
|
379
|
+
return {
|
|
380
|
+
kind: 'page' as const,
|
|
381
|
+
content: textPage.content,
|
|
382
|
+
page: textPage.page,
|
|
383
|
+
page_size: textPage.pageSize,
|
|
384
|
+
total_lines: textPage.totalLines,
|
|
385
|
+
has_more: textPage.hasMore,
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return { kind: 'error' as const, error: String(error instanceof Error ? error.message : error) }
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
}))
|
|
392
|
+
|
|
393
|
+
ctx.tools.register(defineTool({
|
|
394
|
+
name: 'repeat_request',
|
|
395
|
+
description: `Repeat a captured request, optionally patching fields — the browse→capture→modify→test flow. modifications keys: url (replace), params (query additions), headers (additions), body (replace), cookies (additions). Inherits everything else from the original.`,
|
|
396
|
+
parameters: {
|
|
397
|
+
request_id: { type: 'string', required: true, description: 'ID of the original request (from list_requests).' },
|
|
398
|
+
modifications: {
|
|
399
|
+
type: 'object',
|
|
400
|
+
properties: {
|
|
401
|
+
url: { type: 'string', description: 'Replace the URL.' },
|
|
402
|
+
params: { type: 'object', properties: {}, additionalProperties: true, description: 'Query-string keys to add/update.' },
|
|
403
|
+
headers: { type: 'object', properties: {}, additionalProperties: true, description: 'Headers to add/update.' },
|
|
404
|
+
body: { type: 'string', description: 'Replace the body.' },
|
|
405
|
+
cookies: { type: 'object', properties: {}, additionalProperties: true, description: 'Cookies to add/update.' },
|
|
406
|
+
},
|
|
407
|
+
additionalProperties: false,
|
|
408
|
+
description: 'Patch dict overlaying the original request.',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
output: {
|
|
412
|
+
schema: {
|
|
413
|
+
type: 'object',
|
|
414
|
+
properties: {
|
|
415
|
+
success: { type: 'boolean', required: true },
|
|
416
|
+
status: { type: 'string' },
|
|
417
|
+
session_id: { type: 'string' },
|
|
418
|
+
elapsed_ms: { type: 'integer' },
|
|
419
|
+
response: { type: 'object', properties: {}, additionalProperties: true },
|
|
420
|
+
error: { type: 'string' },
|
|
421
|
+
},
|
|
422
|
+
additionalProperties: false,
|
|
423
|
+
},
|
|
424
|
+
render: (_args, value) => {
|
|
425
|
+
const result = value as { success: boolean; status?: string; error?: string; response?: { status_code?: number } | null }
|
|
426
|
+
if (!result.success) return [{ type: 'text', text: `repeat_request failed: ${result.error ?? result.status ?? 'unknown'}` }]
|
|
427
|
+
return [{ type: 'text', text: `replay ${result.status ?? 'DONE'}${result.response?.status_code === undefined ? '' : ` → HTTP ${String(result.response.status_code)}`}` }]
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
execute: async (args, exec) => {
|
|
431
|
+
try {
|
|
432
|
+
const caido = await client(exec.signal)
|
|
433
|
+
const stored = await caido.getRequest(args.request_id, exec.signal)
|
|
434
|
+
if (stored === null) return { success: false, status: 'ERROR', error: `Request ${args.request_id} not found` }
|
|
435
|
+
// Scope enforcement (beyond strix parity): the replay target must be
|
|
436
|
+
// inside the authorized target list before anything is dispatched.
|
|
437
|
+
const allowlist = authorizedTargets()
|
|
438
|
+
const targetHost = resolveRepeatTargetHost(stored.host, args.modifications as Readonly<Record<string, unknown>> | undefined)
|
|
439
|
+
if (!targetHostAllowed(targetHost, allowlist)) {
|
|
440
|
+
return { success: false, status: 'ERROR', error: `repeat_request refused: target host '${targetHost}' is outside this scan's authorized targets` }
|
|
441
|
+
}
|
|
442
|
+
const replay = await caido.replayRequest(args.request_id, args.modifications, exec.signal, { dispatchTimeoutMs: replayTimeoutMs, pollIntervalMs: 500 })
|
|
443
|
+
if (replay === null) return { success: false, status: 'ERROR', error: `Request ${args.request_id} not found` }
|
|
444
|
+
return {
|
|
445
|
+
success: replay.status === 'DONE',
|
|
446
|
+
status: replay.status,
|
|
447
|
+
session_id: replay.sessionId,
|
|
448
|
+
elapsed_ms: replay.elapsedMs,
|
|
449
|
+
response: replay.response === null ? {} : {
|
|
450
|
+
status_code: replay.response.statusCode,
|
|
451
|
+
length: replay.response.length,
|
|
452
|
+
headers: replay.response.headers,
|
|
453
|
+
body: replay.response.body,
|
|
454
|
+
body_truncated: replay.response.bodyTruncated,
|
|
455
|
+
},
|
|
456
|
+
...(replay.error !== undefined ? { error: replay.error } : {}),
|
|
457
|
+
}
|
|
458
|
+
} catch (error) {
|
|
459
|
+
return { success: false, status: 'ERROR', error: String(error instanceof Error ? error.message : error) }
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
}))
|
|
463
|
+
|
|
464
|
+
ctx.tools.register(defineTool({
|
|
465
|
+
name: 'list_sitemap',
|
|
466
|
+
description: `Browse Caido's hierarchical sitemap: DOMAIN → DIRECTORY → REQUEST → REQUEST_BODY/REQUEST_QUERY. Start with no parent_id for root domains (optionally scope-filtered); drill in by passing an entry's id as parent_id (depth DIRECT or ALL). Pair with view_sitemap_entry.`,
|
|
467
|
+
parameters: {
|
|
468
|
+
scope_id: { type: 'string', description: 'Limit roots to a Caido scope (only without parent_id).' },
|
|
469
|
+
parent_id: { type: 'string', description: 'Entry ID to expand; omit for root domains.' },
|
|
470
|
+
depth: { type: 'string', enum: ['DIRECT', 'ALL'], description: 'DIRECT children or the full subtree (default DIRECT).' },
|
|
471
|
+
page: { type: 'integer', description: '1-indexed page (30 entries per page, strix parity).' },
|
|
472
|
+
},
|
|
473
|
+
output: {
|
|
474
|
+
schema: {
|
|
475
|
+
type: 'object',
|
|
476
|
+
properties: {
|
|
477
|
+
success: { type: 'boolean', required: true },
|
|
478
|
+
payload: { type: 'object', properties: {}, additionalProperties: true },
|
|
479
|
+
error: { type: 'string' },
|
|
480
|
+
},
|
|
481
|
+
additionalProperties: false,
|
|
482
|
+
},
|
|
483
|
+
render: (_args, value) => {
|
|
484
|
+
const result = value as { success: boolean; error?: string }
|
|
485
|
+
return [{ type: 'text', text: result.success ? 'sitemap payload returned' : `list_sitemap failed: ${result.error ?? 'unknown'}` }]
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
execute: async (args, exec) => {
|
|
489
|
+
try {
|
|
490
|
+
const caido = await client(exec.signal)
|
|
491
|
+
const payload = await caido.listSitemap({ scopeId: args.scope_id, parentId: args.parent_id, depth: args.depth }, exec.signal) as Record<string, unknown>
|
|
492
|
+
return { success: true as const, payload: structuredToPlain(pageSitemapPayload(payload, args.page ?? 1)) }
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return { success: false as const, payload: {}, error: String(error instanceof Error ? error.message : error) }
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
}))
|
|
498
|
+
|
|
499
|
+
ctx.tools.register(defineTool({
|
|
500
|
+
name: 'scope_rules',
|
|
501
|
+
description: "Manage Caido scope rules: get/list/create/update/delete. Glob patterns (*, ?, [abc], [a-z], [^abc]); an EMPTY allowlist ALLOWS ALL; the denylist overrides the allowlist. Scopes feed list_requests' scope_id.",
|
|
502
|
+
parameters: {
|
|
503
|
+
action: { type: 'string', required: true, enum: ['get', 'list', 'create', 'update', 'delete'], description: 'Scope action.' },
|
|
504
|
+
allowlist: { type: 'array', items: { type: 'string' }, description: 'Allow glob patterns (create/update).' },
|
|
505
|
+
denylist: { type: 'array', items: { type: 'string' }, description: 'Deny glob patterns; overrides the allowlist (create/update).' },
|
|
506
|
+
scope_id: { type: 'string', description: 'Target scope id (get/update/delete).' },
|
|
507
|
+
scope_name: { type: 'string', description: 'Scope name (create/update).' },
|
|
508
|
+
},
|
|
509
|
+
output: {
|
|
510
|
+
schema: {
|
|
511
|
+
type: 'object',
|
|
512
|
+
properties: {
|
|
513
|
+
success: { type: 'boolean', required: true },
|
|
514
|
+
scopes: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } },
|
|
515
|
+
scope: { type: 'object', properties: {}, additionalProperties: true },
|
|
516
|
+
deleted: { type: 'string' },
|
|
517
|
+
message: { type: 'string' },
|
|
518
|
+
error: { type: 'string' },
|
|
519
|
+
},
|
|
520
|
+
additionalProperties: false,
|
|
521
|
+
},
|
|
522
|
+
render: (_args, value) => {
|
|
523
|
+
const result = value as { success: boolean; error?: string }
|
|
524
|
+
return [{ type: 'text', text: result.success ? 'scope action succeeded' : `scope_rules failed: ${result.error ?? 'unknown'}` }]
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
execute: (async (rawArgs: never, rawExec: never) => {
|
|
528
|
+
const args = rawArgs as never as { action: string; allowlist?: string[]; denylist?: string[]; scope_id?: string; scope_name?: string }
|
|
529
|
+
const exec = rawExec as never as ToolRunContextLike
|
|
530
|
+
try {
|
|
531
|
+
const caido = await client(exec.signal)
|
|
532
|
+
const allowlist = args.allowlist ?? []
|
|
533
|
+
const denylist = args.denylist ?? []
|
|
534
|
+
switch (args.action) {
|
|
535
|
+
case 'list': {
|
|
536
|
+
const payload = await caido.scopeList(exec.signal) as { scopes?: unknown[] }
|
|
537
|
+
return { success: true, scopes: plainScopes(payload.scopes ?? []) }
|
|
538
|
+
}
|
|
539
|
+
case 'get': {
|
|
540
|
+
if (args.scope_id === undefined || args.scope_id === '') return { success: false, error: "Scope_id is required for action='get'" }
|
|
541
|
+
return { success: true, scope: plainScope(await caido.scopeGet(args.scope_id, exec.signal)) }
|
|
542
|
+
}
|
|
543
|
+
case 'create': {
|
|
544
|
+
if (args.scope_name === undefined || args.scope_name === '') return { success: false, error: "Scope_name is required for action='create'" }
|
|
545
|
+
return { success: true, scope: plainScope(await caido.scopeCreate(args.scope_name, allowlist, denylist, exec.signal)) }
|
|
546
|
+
}
|
|
547
|
+
case 'update': {
|
|
548
|
+
if (args.scope_id === undefined || args.scope_id === '' || args.scope_name === undefined || args.scope_name === '') {
|
|
549
|
+
return { success: false, error: "Scope_id and scope_name are required for action='update'" }
|
|
550
|
+
}
|
|
551
|
+
return { success: true, scope: plainScope(await caido.scopeUpdate(args.scope_id, args.scope_name, allowlist, denylist, exec.signal)) }
|
|
552
|
+
}
|
|
553
|
+
case 'delete': {
|
|
554
|
+
if (args.scope_id === undefined || args.scope_id === '') return { success: false, error: "Scope_id is required for action='delete'" }
|
|
555
|
+
const payload = await caido.scopeDelete(args.scope_id, exec.signal) as { deleteScope?: { deletedId?: string } }
|
|
556
|
+
return { success: true, deleted: payload.deleteScope?.deletedId ?? args.scope_id, message: `Scope ${args.scope_id} deleted` }
|
|
557
|
+
}
|
|
558
|
+
default:
|
|
559
|
+
return { success: false, error: `Unknown action: ${String(args.action)}` }
|
|
560
|
+
}
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return { success: false, error: `scope_rules failed: ${String(error instanceof Error ? error.message : error)}` }
|
|
563
|
+
}
|
|
564
|
+
}) as never,
|
|
565
|
+
}))
|
|
566
|
+
|
|
567
|
+
ctx.tools.register(defineTool({
|
|
568
|
+
name: 'view_sitemap_entry',
|
|
569
|
+
description: "Full detail for a sitemap entry plus its most recent 30 related requests. Pick entry_id from list_sitemap.",
|
|
570
|
+
parameters: {
|
|
571
|
+
entry_id: { type: 'string', required: true, description: 'ID from list_sitemap (or any nested entry).' },
|
|
572
|
+
},
|
|
573
|
+
output: {
|
|
574
|
+
schema: {
|
|
575
|
+
type: 'object',
|
|
576
|
+
properties: {
|
|
577
|
+
success: { type: 'boolean', required: true },
|
|
578
|
+
payload: { type: 'object', properties: {}, additionalProperties: true },
|
|
579
|
+
error: { type: 'string' },
|
|
580
|
+
},
|
|
581
|
+
additionalProperties: false,
|
|
582
|
+
},
|
|
583
|
+
render: (_args, value) => {
|
|
584
|
+
const result = value as { success: boolean; error?: string }
|
|
585
|
+
return [{ type: 'text', text: result.success ? 'sitemap entry payload returned' : `view_sitemap_entry failed: ${result.error ?? 'unknown'}` }]
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
execute: async (args, exec) => {
|
|
589
|
+
try {
|
|
590
|
+
const caido = await client(exec.signal)
|
|
591
|
+
const payload = await caido.viewSitemapEntry(args.entry_id, exec.signal) as Record<string, unknown>
|
|
592
|
+
return { success: true as const, payload: structuredToPlain(payload) }
|
|
593
|
+
} catch (error) {
|
|
594
|
+
return { success: false as const, payload: {}, error: String(error instanceof Error ? error.message : error) }
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
}))
|
|
598
|
+
}
|