From e02fa53cef52166a12a84c5a55fbcdcd0657a09c Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Tue, 23 Jun 2026 13:27:13 -0300 Subject: [PATCH 01/13] fix replay send to use public sdk.replay API --- .../src/components/attacks/Container.vue | 56 +-- packages/frontend/src/services/replay.ts | 364 +++--------------- 2 files changed, 78 insertions(+), 342 deletions(-) diff --git a/packages/frontend/src/components/attacks/Container.vue b/packages/frontend/src/components/attacks/Container.vue index 4ff5601..e75b371 100644 --- a/packages/frontend/src/components/attacks/Container.vue +++ b/packages/frontend/src/components/attacks/Container.vue @@ -1375,45 +1375,29 @@ const createFindingFromResult = async (result: AttackResult) => { }; const sendToReplay = async (result: AttackResult) => { - try { - if (result.rawRequest === undefined || result.rawRequest === "") { - sdk.window.showToast("No request data available for replay", { - variant: "error", - }); - return; - } - - let domain = "Unknown"; - try { - if (result.targetUrl) { - const url = new URL(result.targetUrl); - domain = url.hostname; - } - } catch (error) { - domain = "Unknown"; - } + if (result.rawRequest === undefined || result.rawRequest === "") { + sdk.window.showToast("No request data available for replay", { + variant: "error", + }); + return; + } - const replayResult = await replayService.createReplayFromRequest( - result.rawRequest, - domain, - ); + const replayResult = await replayService.createReplayFromRequest( + result.rawRequest, + result.targetUrl, + ); - if (replayResult.kind === "Ok") { - sdk.window.showToast( - `Created replay session: ${replayResult.value.sessionName}`, - { variant: "success" }, - ); - } else { - sdk.window.showToast(`Failed to create replay: ${replayResult.error}`, { - variant: "error", - }); - } - } catch (error) { - sdk.window.showToast( - `Error sending to replay: ${error instanceof Error ? error.message : "Unknown error"}`, - { variant: "error" }, - ); + if (replayResult.kind === "Error") { + sdk.window.showToast(`Failed to create replay: ${replayResult.error}`, { + variant: "error", + }); + return; } + + sdk.window.showToast( + `Created replay session: ${replayResult.value.sessionName}`, + { variant: "success" }, + ); }; const getAttackTypeLabel = (attackType: string) => { diff --git a/packages/frontend/src/services/replay.ts b/packages/frontend/src/services/replay.ts index 75fce4b..c84c59a 100644 --- a/packages/frontend/src/services/replay.ts +++ b/packages/frontend/src/services/replay.ts @@ -1,340 +1,92 @@ -import type { FrontendSDK } from "../plugins/sdk"; +import type { Result } from "shared"; -export type Result = - | { kind: "Ok"; value: T } - | { kind: "Error"; error: string }; +import type { FrontendSDK } from "@/plugins/sdk"; -type RequestSpec = { - method: string; +type ConnectionInfo = { host: string; port: number; - path: string; - query: string; - headers: Record; - body: string; - tls: boolean; - url: string; + isTLS: boolean; }; -export class GraphQLReplayService { - private sdk: FrontendSDK; - private collections: Map = new Map(); +const collectionPrefix = "GraphQL - "; - constructor(sdk: FrontendSDK) { - this.sdk = sdk; +const parseConnection = (targetUrl: string): ConnectionInfo | undefined => { + if (!URL.canParse(targetUrl)) { + return undefined; } - async createReplayFromRequest( - rawRequest: string, - domain: string, - ): Promise> { - try { - const parsedRequest = this.parseRawHttpRequest(rawRequest); - if (parsedRequest === null) { - return { kind: "Error", error: "Failed to parse HTTP request" }; - } - - const collectionName = this.getOrCreateCollection(domain); - - if (parsedRequest === undefined) { - return { - kind: "Error", - error: "Failed to parse HTTP request", - }; - } - const sessionName = this.generateSessionName(parsedRequest); - - const requestSpec = this.buildRequestSpec(parsedRequest); - if (requestSpec === undefined) { - return { - kind: "Error", - error: "Failed to build request specification", - }; - } + const url = new URL(targetUrl); + const isTLS = url.protocol === "https:"; + const port = url.port !== "" ? Number(url.port) : isTLS ? 443 : 80; - await this.sendToReplay(requestSpec, collectionName, sessionName); + return { host: url.hostname, port, isTLS }; +}; - return { - kind: "Ok", - value: { - collectionName, - sessionName, - }, - }; - } catch (error) { - return { - kind: "Error", - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } +const sessionNameFromRaw = (rawRequest: string): string => { + const parts = (rawRequest.split("\n")[0]?.trim() ?? "").split(" "); + const method = parts[0] ?? "POST"; + const path = (parts[1] ?? "/").split("?")[0] ?? "/"; - private getOrCreateCollection(domain: string): string { - const collectionName = `GraphQL - ${domain}`; + return `${method} ${path}`; +}; - if (!this.collections.has(domain)) { - this.collections.set(domain, collectionName); - } +export class GraphQLReplayService { + private sdk: FrontendSDK; - return collectionName; + constructor(sdk: FrontendSDK) { + this.sdk = sdk; } - private parseRawHttpRequest( + async createReplayFromRequest( rawRequest: string, - ): ParsedHttpRequest | undefined { - try { - const lines = rawRequest.split("\n"); - if (lines.length === 0) return undefined; - - const requestLine = lines[0]?.trim(); - if (requestLine === undefined || requestLine === "") return undefined; - const parts = requestLine.split(" "); - const method = parts[0]; - const path = parts[1]; - const protocol = parts[2]; - if (method === undefined || path === undefined) return undefined; - - const headers: Record = {}; - let bodyStartIndex = -1; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]?.trim(); - if (line === undefined || line === "") { - bodyStartIndex = i + 1; - break; - } - - const colonIndex = line.indexOf(":"); - if (colonIndex > 0) { - const headerName = line.substring(0, colonIndex).trim(); - const headerValue = line.substring(colonIndex + 1).trim(); - if (headerName !== "" && headerValue !== "") { - headers[headerName] = headerValue; - } - } - } - - let body = ""; - if (bodyStartIndex > 0 && bodyStartIndex < lines.length) { - body = lines.slice(bodyStartIndex).join("\n").trim(); - } - - const host = headers["Host"] ?? headers["host"] ?? "localhost"; - const tls = - (protocol !== undefined && protocol.includes("HTTPS")) || - headers["X-Forwarded-Proto"] === "https"; - - let port = 80; - if (tls === true) port = 443; - if (host.includes(":")) { - const parts = host.split(":"); - const portStr = parts[1]; - if (portStr !== undefined && portStr !== "") { - const parsedPort = parseInt(portStr); - port = Number.isNaN(parsedPort) ? port : parsedPort; - } - } - - return { - method: method.toUpperCase(), - path: path ?? "/", - host: host.split(":")[0] ?? "localhost", - port, - tls, - headers, - body, - protocol: protocol ?? "HTTP/1.1", - }; - } catch (error) { - return undefined; - } - } - - private buildRequestSpec( - parsedRequest: ParsedHttpRequest, - ): RequestSpec | undefined { - try { - const protocol = parsedRequest.tls === true ? "https" : "http"; - const portStr = - (parsedRequest.tls === true && parsedRequest.port === 443) || - (parsedRequest.tls === false && parsedRequest.port === 80) - ? "" - : `:${parsedRequest.port}`; - const url = `${protocol}://${parsedRequest.host}${portStr}${parsedRequest.path}`; - - const [pathname, queryString] = parsedRequest.path.split("?"); - - return { - method: parsedRequest.method, - host: parsedRequest.host, - port: parsedRequest.port, - path: pathname ?? "/", - query: queryString ?? "", - headers: parsedRequest.headers, - body: parsedRequest.body, - tls: parsedRequest.tls, - url, - }; - } catch (error) { - return undefined; + targetUrl: string, + ): Promise> { + const connection = parseConnection(targetUrl); + if (connection === undefined) { + return { kind: "Error", error: "Invalid target URL" }; } - } - private generateSessionName(parsedRequest: ParsedHttpRequest): string { - const path = parsedRequest.path.split("?")[0]; - return `${parsedRequest.method} ${path}`; - } + const collectionName = `${collectionPrefix}${connection.host}`; + const sessionName = sessionNameFromRaw(rawRequest); - private async sendToReplay( - requestSpec: RequestSpec, - collectionName: string, - sessionName: string, - ): Promise { try { - let collectionId: string | undefined; - - try { - const collections = this.sdk.replay.getCollections(); - type Collection = { name: string; id?: string }; - const existingCollection = collections.find( - (c: Collection) => c.name === collectionName, - ); - collectionId = existingCollection?.id; - } catch { - // Ignore replay SDK errors if not available - collectionId = undefined; - } - - if (collectionId === undefined) { - const createCollectionResult = - await this.sdk.graphql.createReplaySessionCollection({ - input: { - name: collectionName, - }, - }); - - collectionId = - createCollectionResult.createReplaySessionCollection?.collection?.id; - if (collectionId === undefined) { - throw new Error("Failed to create replay collection"); - } - } - - const rawRequest = this.buildRawHttpRequest(requestSpec); - - const createSessionResult = await this.sdk.graphql.createReplaySession({ - input: { - requestSource: { - raw: { - raw: rawRequest, - connectionInfo: { - host: requestSpec.host ?? "localhost", - port: requestSpec.port ?? (requestSpec.tls === true ? 443 : 80), - isTLS: requestSpec.tls === true, - }, - }, - }, - }, - }); - - const sessionId = createSessionResult.createReplaySession?.session?.id; - if (sessionId === undefined) { - throw new Error("Failed to create replay session"); - } - - try { - type ReplaySDK = { - moveSession?: ( - sessionId: string, - collectionId: string, - ) => Promise; - }; - const replaySDK = this.sdk.replay as unknown as ReplaySDK; - if (replaySDK.moveSession !== undefined) { - await replaySDK.moveSession(sessionId, collectionId); - } - } catch { - // Ignore - } - - try { - await this.sdk.graphql.renameReplaySession({ - id: sessionId, - name: sessionName, - }); - } catch { - // Ignore - } - } catch (error) { - throw new Error( - `Failed to send to replay: ${error instanceof Error ? error.message : "Unknown error"}`, + const existing = this.sdk.replay + .getCollections() + .find((collection) => collection.name === collectionName); + const collectionId = + existing?.id ?? + (await this.sdk.replay.createCollection(collectionName)).id; + + const existingIds = new Set( + this.sdk.replay.getSessions().map((session) => session.id), ); - } - } - - private buildRawHttpRequest(spec: RequestSpec): string { - try { - const method = spec.method ?? "POST"; - const host = spec.host ?? "localhost"; - const port = spec.port ?? (spec.tls === true ? 443 : 80); - const path = spec.path ?? "/"; - const query = spec.query !== "" ? `?${spec.query}` : ""; - const headers = spec.headers ?? {}; - const body = spec.body ?? ""; - const isTls = spec.tls === true; - const fullPath = path + query; - let request = `${method} ${fullPath} HTTP/1.1\r\n`; - - if ( - (isTls === true && port !== 443) || - (isTls === false && port !== 80) - ) { - request += `Host: ${host}:${port}\r\n`; - } else { - request += `Host: ${host}\r\n`; - } - - for (const [name, value] of Object.entries(headers)) { - const lowerName = name.toLowerCase(); - if ( - name !== "" && - value !== "" && - lowerName !== "host" && - lowerName !== "content-length" - ) { - request += `${name}: ${value}\r\n`; - } - } - - if (typeof body === "string" && body.length > 0) { - request += `Content-Length: ${body.length}\r\n`; - } - - request += "\r\n"; + await this.sdk.replay.createSession( + { type: "Raw", raw: rawRequest, connectionInfo: connection }, + collectionId, + ); - if (typeof body === "string" && body.length > 0) { - request += body; + const created = this.sdk.replay + .getSessions() + .find( + (session) => + session.collectionId === collectionId && + !existingIds.has(session.id), + ); + if (created !== undefined) { + await this.sdk.replay.renameSession(created.id, sessionName); } - return request; + return { kind: "Ok", value: { collectionName, sessionName } }; } catch (error) { - return `POST / HTTP/1.1\r\nHost: localhost\r\n\r\n`; + return { + kind: "Error", + error: error instanceof Error ? error.message : "Unknown error", + }; } } } -interface ParsedHttpRequest { - method: string; - path: string; - host: string; - port: number; - tls: boolean; - headers: Record; - body: string; - protocol: string; -} - let replayServiceInstance: GraphQLReplayService | undefined = undefined; export function createReplayService(sdk: FrontendSDK): GraphQLReplayService { From 4386b99e944c7695ddfabf94130f7c3952059b20 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Tue, 23 Jun 2026 13:28:36 -0300 Subject: [PATCH 02/13] Bump Version --- caido.config.ts | 66 ++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/caido.config.ts b/caido.config.ts index c41fd69..7856ad5 100644 --- a/caido.config.ts +++ b/caido.config.ts @@ -1,18 +1,20 @@ -import { defineConfig } from '@caido-community/dev'; -import vue from '@vitejs/plugin-vue'; +import path from "path"; + +import tailwindCaido from "@caido/tailwindcss"; +import { defineConfig } from "@caido-community/dev"; +import vue from "@vitejs/plugin-vue"; +import prefixwrap from "postcss-prefixwrap"; import tailwindcss from "tailwindcss"; // @ts-expect-error no declared types at this time import tailwindPrimeui from "tailwindcss-primeui"; -import tailwindCaido from "@caido/tailwindcss"; -import path from "path"; -import prefixwrap from "postcss-prefixwrap"; const id = "graphql-analyzer"; export default defineConfig({ id, name: "GraphQL Analyzer", - description: "Plugin for GraphQL schema discovery, visualization, and advanced security", - version: "1.0.4", + description: + "Plugin for GraphQL schema discovery, visualization, and advanced security", + version: "1.0.5", author: { name: "Amr Elsagaei", email: "info@amrelsagaei.com", @@ -25,9 +27,9 @@ export default defineConfig({ root: "packages/backend", }, { - kind: 'frontend', + kind: "frontend", id: "frontend", - root: 'packages/frontend', + root: "packages/frontend", backend: { id: "backend", }, @@ -36,21 +38,20 @@ export default defineConfig({ build: { rollupOptions: { external: [ - '@caido/frontend-sdk', - "@codemirror/autocomplete", - "@codemirror/commands", - "@codemirror/language", - "@codemirror/lint", - "@codemirror/search", - "@codemirror/state", - "@codemirror/view", - "@lezer/common", - "@lezer/highlight", + "@caido/frontend-sdk", + "@codemirror/autocomplete", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", "@lezer/lr", "vue", - - ] - } + ], + }, }, resolve: { alias: [ @@ -72,25 +73,24 @@ export default defineConfig({ preflight: false, }, content: [ - './packages/frontend/src/**/*.{vue,ts}', - './node_modules/@caido/primevue/dist/primevue.mjs' + "./packages/frontend/src/**/*.{vue,ts}", + "./node_modules/@caido/primevue/dist/primevue.mjs", ], // Check the [data-mode="dark"] attribute on the element to determine the mode // This attribute is set in the Caido core application darkMode: ["selector", '[data-mode="dark"]'], plugins: [ - // This plugin injects the necessary Tailwind classes for PrimeVue components tailwindPrimeui, // This plugin injects the necessary Tailwind classes for the Caido theme tailwindCaido, ], - }) - ] - } - } - } - } - ] -}); \ No newline at end of file + }), + ], + }, + }, + }, + }, + ], +}); From 2b3b3e9e826a0f29e1261da1be261db16de18a16 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Tue, 23 Jun 2026 14:26:14 -0300 Subject: [PATCH 03/13] bump Caido SDK to 0.57 for the replay draft view-mode API --- packages/backend/package.json | 2 +- packages/frontend/package.json | 4 +- pnpm-lock.yaml | 74 ++++++++++++++++------------------ 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index dcb0046..5aaad0f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -11,6 +11,6 @@ "zod": "4.3.6" }, "devDependencies": { - "@caido/sdk-backend": "^0.51.0" + "@caido/sdk-backend": "0.57.0" } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 051e90f..2bd759b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,8 +19,8 @@ "vue": "3.4.37" }, "devDependencies": { - "@caido/sdk-backend": "^0.51.0", - "@caido/sdk-frontend": "^0.51.2-beta.0", + "@caido/sdk-backend": "0.57.0", + "@caido/sdk-frontend": "0.57.1-beta.6", "backend": "workspace:*", "shared": "workspace:*", "vue-tsc": "2.0.29" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7a314e..1adf92f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,8 +62,8 @@ importers: version: 4.3.6 devDependencies: '@caido/sdk-backend': - specifier: ^0.51.0 - version: 0.51.1 + specifier: 0.57.0 + version: 0.57.0 packages/frontend: dependencies: @@ -102,11 +102,11 @@ importers: version: 3.4.37(typescript@5.5.4) devDependencies: '@caido/sdk-backend': - specifier: ^0.51.0 - version: 0.51.1 + specifier: 0.57.0 + version: 0.57.0 '@caido/sdk-frontend': - specifier: ^0.51.2-beta.0 - version: 0.51.2-beta.0(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4)) + specifier: 0.57.1-beta.6 + version: 0.57.1-beta.6(@ai-sdk/provider@3.0.10)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4)) backend: specifier: workspace:* version: link:../backend @@ -125,6 +125,10 @@ importers: packages: + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -163,21 +167,22 @@ packages: '@caido/primevue@0.1.2': resolution: {integrity: sha512-PCv6qyvlWEKB97PLRzPHDjs7ZGW5JQrD6jvJiMEiprfu4IR5/dXiqRJ4M5g293qn+RAffBuiXcna0vWSxrg5lQ==} - '@caido/quickjs-types@0.20.0': - resolution: {integrity: sha512-T3OTw3GjGPtMxASFHZro86Jce+9TVNNbUz2fQCaCbP+Yh+olt5mwTMLNz8uLFo4icgc1WPfyimfFSN23za9UXw==} + '@caido/quickjs-types@0.26.0': + resolution: {integrity: sha512-Nnpu87fnTvVxEdLUQXAc9d763vj13GPcQEhSWireuLlDPDgxM/7dK8kZdOhGX/BhWRRZ7nUzNc3IdN+OVdsoSA==} - '@caido/sdk-backend@0.51.1': - resolution: {integrity: sha512-BvDCcbxXCDcsCvqntpeoKJ475DUZ7H7TXZjIPcoa5WU+ynBpvnDpeoxVoy1UBYQKz9L5ld8MQ3S5oY9wEoNHqQ==} + '@caido/sdk-backend@0.57.0': + resolution: {integrity: sha512-GwPytq4g+lhrLXkwkAHOzp8CQuPEfU+zAC92o1HDZa0gkeDWwKB3j0zi9HgdF8dgyARY5MV3s67ebljGrC0ywQ==} - '@caido/sdk-frontend@0.51.2-beta.0': - resolution: {integrity: sha512-PJfPOQ8auWjjEyoOtBJr5ogzUMOk+uVqUh3QmjRcZNp4jcnm6hx6/M2O4PMHhXC3ihfCfHCqYnFyOLqgqXEpRg==} + '@caido/sdk-frontend@0.57.1-beta.6': + resolution: {integrity: sha512-Lx65PSitf5XifG2l4xF3c4kvO3Zj60CtL+n/cUuHXLFRLB1Dw4wYt/JXTRCysvxMoI9/OSB20rixH5tFpzrliw==} peerDependencies: + '@ai-sdk/provider': ^3.0.1 '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 vue: ^3.0.0 - '@caido/sdk-shared@0.1.1': - resolution: {integrity: sha512-JAV5ajUqxZdXYPTmDEvIKBZon8I5uHq44ATj0Nj3BVpllRDUGY9kcBd+PXMD50+3lv1CvhR3/f6q24T0+4aVJQ==} + '@caido/sdk-shared@0.2.2': + resolution: {integrity: sha512-qzfwXrjujNAmXxedQW2YI5Ls5h+Y/MvzHZxd10vnL6IJbRJStZGusDTnFYDWUyiQpCrc5kGuY3fLr8dYh3UHnw==} '@caido/tailwindcss@0.0.1': resolution: {integrity: sha512-BGp7s8BiZv6eBV8x/j0t5nPBVKP7Bm+gJVY4APcFgFkNkrRSRDo0VuXN52OhiHc/+vTg85lrmLO8IWMM5bcJrQ==} @@ -545,49 +550,41 @@ packages: resolution: {integrity: sha512-xlMh4gNtplNQEwuF5icm69udC7un0WyzT5ywOeHrPMEsghKnLjXok2wZgAA7ocTm9+JsI+nVXIQa5XO1x+HPQg==} cpu: [arm64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.16.2': resolution: {integrity: sha512-OZs33QTMi0xmHv/4P0+RAKXJTBk7UcMH5tpTaCytWRXls/DGaJ48jOHmriQGK2YwUqXl+oneuNyPOUO0obJ+Hg==} cpu: [arm64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.16.2': resolution: {integrity: sha512-UVyuhaV32dJGtF6fDofOcBstg9JwB2Jfnjfb8jGlu3xcG+TsubHRhuTwQ6JZ1sColNT1nMxBiu7zdKUEZi1kwg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.16.2': resolution: {integrity: sha512-YZZS0yv2q5nE1uL/Fk4Y7m9018DSEmDNSG8oJzy1TJjA1jx5HL52hEPxi98XhU6OYhSO/vC1jdkJeE8TIHugug==} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.16.2': resolution: {integrity: sha512-9VYuypwtx4kt1lUcwJAH4dPmgJySh4/KxtAPdRoX2BTaZxVm/yEXHq0mnl/8SEarjzMvXKbf7Cm6UBgptm3DZw==} cpu: [riscv64] os: [linux] - libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.16.2': resolution: {integrity: sha512-3gbwQ+xlL5gpyzgSDdC8B4qIM4mZaPDLaFOi3c/GV7CqIdVJc5EZXW4V3T6xwtPBOpXPXfqQLbhTnUD4SqwJtA==} cpu: [s390x] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.16.2': resolution: {integrity: sha512-m0WcK0j54tSwWa+hQaJMScZdWneqE7xixp/vpFqlkbhuKW9dRHykPAFvSYg1YJ3MJgu9ZzVNpYHhPKJiEQq57Q==} cpu: [x64] os: [linux] - libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.16.2': resolution: {integrity: sha512-ZjUm3w96P2t47nWywGwj1A2mAVBI/8IoS7XHhcogWCfXnEI3M6NPIRQPYAZW4s5/u3u6w1uPtgOwffj2XIOb/g==} cpu: [x64] os: [linux] - libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.16.2': resolution: {integrity: sha512-OFVQ2x3VenTp13nIl6HcQ/7dmhFmM9dg2EjKfHcOtYfrVLQdNR6THFU7GkMdmc8DdY1zLUeilHwBIsyxv5hkwQ==} @@ -674,67 +671,56 @@ packages: resolution: {integrity: sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.0': resolution: {integrity: sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.0': resolution: {integrity: sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.0': resolution: {integrity: sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.50.0': resolution: {integrity: sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.50.0': resolution: {integrity: sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.50.0': resolution: {integrity: sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.0': resolution: {integrity: sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.0': resolution: {integrity: sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.0': resolution: {integrity: sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.0': resolution: {integrity: sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.50.0': resolution: {integrity: sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==} @@ -2094,6 +2080,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3090,6 +3079,10 @@ packages: snapshots: + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@babel/helper-string-parser@7.27.1': {} @@ -3165,20 +3158,21 @@ snapshots: '@caido/primevue@0.1.2': {} - '@caido/quickjs-types@0.20.0': {} + '@caido/quickjs-types@0.26.0': {} - '@caido/sdk-backend@0.51.1': + '@caido/sdk-backend@0.57.0': dependencies: - '@caido/quickjs-types': 0.20.0 - '@caido/sdk-shared': 0.1.1 + '@caido/quickjs-types': 0.26.0 + '@caido/sdk-shared': 0.2.2 - '@caido/sdk-frontend@0.51.2-beta.0(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4))': + '@caido/sdk-frontend@0.57.1-beta.6(@ai-sdk/provider@3.0.10)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(vue@3.4.37(typescript@5.5.4))': dependencies: + '@ai-sdk/provider': 3.0.10 '@codemirror/state': 6.4.1 '@codemirror/view': 6.28.1 vue: 3.4.37(typescript@5.5.4) - '@caido/sdk-shared@0.1.1': {} + '@caido/sdk-shared@0.2.2': {} '@caido/tailwindcss@0.0.1': dependencies: @@ -5266,6 +5260,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: From b13c5b9eddd5d029d8b02d9d7acf2717ec6a417a Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Tue, 23 Jun 2026 14:26:20 -0300 Subject: [PATCH 04/13] add shared GraphQL detection for JSON, raw, and persisted queries --- packages/frontend/src/utils/graphql.test.ts | 128 ++++++++++++++++++++ packages/frontend/src/utils/graphql.ts | 109 +++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 packages/frontend/src/utils/graphql.test.ts create mode 100644 packages/frontend/src/utils/graphql.ts diff --git a/packages/frontend/src/utils/graphql.test.ts b/packages/frontend/src/utils/graphql.test.ts new file mode 100644 index 0000000..133b9b4 --- /dev/null +++ b/packages/frontend/src/utils/graphql.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { + extractGraphQLOperation, + isGraphQLRequest, + parseHttpMessage, +} from "./graphql"; + +const rawRequest = (method: string, body: string): string => + [ + `${method} /graphql HTTP/1.1`, + "Host: example.com", + "Content-Type: application/json", + `Content-Length: ${body.length}`, + "", + body, + ].join("\r\n"); + +describe("isGraphQLRequest", () => { + it("detects a JSON body with a query field", () => { + const body = JSON.stringify({ + query: + "query IntrospectionQuery {\n __schema {\n queryType { name }\n }\n }", + variables: {}, + }); + expect(isGraphQLRequest(rawRequest("POST", body))).toBe(true); + }); + + it("detects a raw query body with an operation name", () => { + expect( + isGraphQLRequest(rawRequest("POST", "query GetUser { user { id } }")), + ).toBe(true); + }); + + it("detects an anonymous operation", () => { + expect(isGraphQLRequest(rawRequest("POST", "{ user { id } }"))).toBe(true); + }); + + it("detects a mutation", () => { + expect(isGraphQLRequest(rawRequest("POST", "mutation { logout }"))).toBe( + true, + ); + }); + + it("detects an Apollo persisted query without an inline query", () => { + const body = JSON.stringify({ + operationName: "TrackMutation", + variables: { event: "RUN_EXPLORER_OPERATION" }, + extensions: { persistedQuery: { version: 1, sha256Hash: "abc" } }, + }); + expect(isGraphQLRequest(rawRequest("POST", body))).toBe(true); + }); + + it("detects a JSON body with only an operationName", () => { + const body = JSON.stringify({ operationName: "GetUser", variables: {} }); + expect(isGraphQLRequest(rawRequest("POST", body))).toBe(true); + }); + + it("ignores non-GraphQL JSON", () => { + expect( + isGraphQLRequest(rawRequest("POST", JSON.stringify({ name: "x" }))), + ).toBe(false); + }); + + it("ignores non-POST requests", () => { + const body = JSON.stringify({ query: "{ user { id } }" }); + expect(isGraphQLRequest(rawRequest("GET", body))).toBe(false); + }); + + it("ignores empty input", () => { + expect(isGraphQLRequest("")).toBe(false); + }); +}); + +describe("extractGraphQLOperation", () => { + it("returns query, variables and operationName from JSON", () => { + const op = extractGraphQLOperation( + JSON.stringify({ + query: "query Q { a }", + variables: { id: 1 }, + operationName: "Q", + }), + ); + expect(op).toEqual({ + query: "query Q { a }", + variables: { id: 1 }, + operationName: "Q", + }); + }); + + it("returns the persisted query hash and empty query for an APQ body", () => { + const op = extractGraphQLOperation( + JSON.stringify({ + operationName: "UI__IdentityQuery", + variables: { accountId: null }, + extensions: { persistedQuery: { version: 1, sha256Hash: "0b60a8" } }, + }), + ); + expect(op?.query).toBe(""); + expect(op?.operationName).toBe("UI__IdentityQuery"); + expect(op?.persistedQueryHash).toBe("0b60a8"); + }); + + it("returns a raw query body", () => { + expect(extractGraphQLOperation("mutation M { x }")).toEqual({ + query: "mutation M { x }", + }); + }); + + it("returns undefined for JSON without a query", () => { + expect( + extractGraphQLOperation(JSON.stringify({ data: 1 })), + ).toBeUndefined(); + }); + + it("returns undefined for non-GraphQL text", () => { + expect(extractGraphQLOperation("hello world")).toBeUndefined(); + }); +}); + +describe("parseHttpMessage", () => { + it("splits method, headers and body", () => { + const message = parseHttpMessage(rawRequest("POST", "{ a }")); + expect(message?.method).toBe("POST"); + expect(message?.headers["Content-Type"]).toBe("application/json"); + expect(message?.body).toBe("{ a }"); + }); +}); diff --git a/packages/frontend/src/utils/graphql.ts b/packages/frontend/src/utils/graphql.ts new file mode 100644 index 0000000..1ec6ea1 --- /dev/null +++ b/packages/frontend/src/utils/graphql.ts @@ -0,0 +1,109 @@ +export type HttpMessage = { + method: string; + headers: Record; + body: string; +}; + +export type GraphQLOperation = { + query: string; + variables?: unknown; + operationName?: string; + persistedQueryHash?: string; +}; + +export const parseHttpMessage = (raw: string): HttpMessage | undefined => { + if (raw.trim() === "") return undefined; + + let parts = raw.split("\r\n\r\n"); + if (parts.length < 2) { + parts = raw.split("\n\n"); + if (parts.length < 2) return undefined; + } + + const headerSection = parts[0] ?? ""; + const separator = raw.includes("\r\n") ? "\r\n\r\n" : "\n\n"; + const body = parts.slice(1).join(separator); + + const eol = headerSection.includes("\r\n") ? "\r\n" : "\n"; + const lines = headerSection.split(eol); + const method = (lines[0] ?? "").match(/^(\w+)\s+/)?.[1] ?? "UNKNOWN"; + + const headers: Record = {}; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined || line === "") continue; + const colonIndex = line.indexOf(":"); + if (colonIndex > 0) { + const name = line.substring(0, colonIndex).trim(); + const value = line.substring(colonIndex + 1).trim(); + if (name !== "" && value !== "") { + headers[name] = value; + } + } + } + + return { method, headers, body }; +}; + +const operationPattern = /^\s*(query|mutation|subscription|fragment)\b/i; +const anonymousPattern = /^\{\s*[A-Za-z_]/; + +const looksLikeGraphQLQuery = (text: string): boolean => { + const trimmed = text.trim(); + if (trimmed === "") return false; + return operationPattern.test(trimmed) || anonymousPattern.test(trimmed); +}; + +export const extractGraphQLOperation = ( + body: string, +): GraphQLOperation | undefined => { + const trimmed = body.trim(); + if (trimmed === "") return undefined; + + try { + const json = JSON.parse(trimmed) as { + query?: unknown; + variables?: unknown; + operationName?: unknown; + extensions?: { persistedQuery?: { sha256Hash?: unknown } }; + }; + + const query = typeof json.query === "string" ? json.query : ""; + const operationName = + typeof json.operationName === "string" ? json.operationName : undefined; + const hasOperationName = + operationName !== undefined && operationName.trim() !== ""; + const persistedQuery = json.extensions?.persistedQuery; + const persistedQueryHash = + typeof persistedQuery?.sha256Hash === "string" + ? persistedQuery.sha256Hash + : undefined; + + if ( + query.trim() !== "" || + hasOperationName || + persistedQuery !== undefined + ) { + return { + query, + variables: json.variables, + operationName, + persistedQueryHash, + }; + } + + return undefined; + } catch { + if (looksLikeGraphQLQuery(trimmed)) { + return { query: trimmed }; + } + return undefined; + } +}; + +export const isGraphQLRequest = (raw: string): boolean => { + const message = parseHttpMessage(raw); + if (message === undefined) return false; + if (message.method.toUpperCase() !== "POST") return false; + return extractGraphQLOperation(message.body) !== undefined; +}; From 6678d1a292284fa60e3811bbd65ef4ee75545eb7 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Tue, 23 Jun 2026 14:26:25 -0300 Subject: [PATCH 05/13] register the GraphQL view mode per surface with the typed SDK --- packages/frontend/src/index.ts | 73 ++++++---------------------------- 1 file changed, 12 insertions(+), 61 deletions(-) diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 753c2d3..4b22b98 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -1,11 +1,13 @@ import { Classic } from "@caido/primevue"; +import { type RequestDraft, type RequestFull } from "@caido/sdk-frontend"; import PrimeVue from "primevue/config"; import Tooltip from "primevue/tooltip"; -import { createApp } from "vue"; +import { type Component, createApp, markRaw } from "vue"; import { SDKPlugin } from "./plugins/sdk"; import "./styles/index.css"; import type { FrontendSDK } from "./types"; +import { isGraphQLRequest } from "./utils/graphql"; import App from "./views/App.vue"; import GraphQLViewMode from "./views/GraphQLViewMode.vue"; @@ -39,69 +41,18 @@ export const init = (sdk: FrontendSDK) => { icon: "fas fa-project-diagram", }); - // Detect whether a raw HTTP request contains a GraphQL query - function isGraphQLRequest(raw: string): boolean { - if (raw === "" || raw.trim() === "") return false; - - // Split headers from body - let parts = raw.split("\r\n\r\n"); - if (parts.length < 2) { - parts = raw.split("\n\n"); - if (parts.length < 2) return false; - } - - const headerSection = parts[0] ?? ""; - const firstLine = headerSection.split(/\r?\n/)[0] ?? ""; - - // Must be a POST request - if (!firstLine.startsWith("POST ")) return false; - - const separator = raw.includes("\r\n") ? "\r\n\r\n" : "\n\n"; - const body = parts.slice(1).join(separator).trim(); - if (!body) return false; - - try { - const parsed = JSON.parse(body) as { query?: unknown }; - return typeof parsed.query === "string" && parsed.query.trim() !== ""; - } catch { - return false; - } - } - - type ViewModeOptions = { - label: string; - view: { component: unknown }; - when?: (...args: unknown[]) => boolean; - }; - - type ExtendedViewModeSDK = { - addRequestViewMode: (options: ViewModeOptions) => void; - }; - - const requestViewMode: ViewModeOptions = { + const viewMode = { label: "GraphQL", - view: { component: GraphQLViewMode }, - when: (request: unknown) => { - const req = request as { raw?: string } | undefined; - return isGraphQLRequest(req?.raw ?? ""); - }, + view: { component: markRaw(GraphQLViewMode) as Component }, + when: (request: RequestFull | RequestDraft) => + isGraphQLRequest(request.raw), }; - const surfaces = [ - sdk.httpHistory, - sdk.replay, - sdk.search, - sdk.sitemap, - sdk.intercept, - ] as unknown as ExtendedViewModeSDK[]; - - for (const surface of surfaces) { - try { - surface.addRequestViewMode(requestViewMode); - } catch { - // ignore - } - } + sdk.httpHistory.addRequestViewMode(viewMode); + sdk.search.addRequestViewMode(viewMode); + sdk.sitemap.addRequestViewMode(viewMode); + sdk.replay.addRequestViewMode(viewMode); + sdk.intercept.addRequestViewMode(viewMode); sdk.commands.register("graphql-analyzer-scan", { name: "Scan GraphQL Endpoint", From 1e04f34c498c87e458b95a792126e8a9c33ea6e3 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Tue, 23 Jun 2026 14:26:30 -0300 Subject: [PATCH 06/13] make the GraphQL replay view mode editable via the draft API --- .../frontend/src/views/GraphQLViewMode.vue | 595 +++++------------- 1 file changed, 146 insertions(+), 449 deletions(-) diff --git a/packages/frontend/src/views/GraphQLViewMode.vue b/packages/frontend/src/views/GraphQLViewMode.vue index d3c4560..4eab194 100644 --- a/packages/frontend/src/views/GraphQLViewMode.vue +++ b/packages/frontend/src/views/GraphQLViewMode.vue @@ -1,18 +1,26 @@