diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5c647777..45148458 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -238,7 +238,9 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => await app.register(cors, { origin: env.CORS_ORIGIN ? env.CORS_ORIGIN.split(",").map((s) => s.trim()) - : process.env.NODE_ENV !== "production", + : process.env.NODE_ENV === "production" + ? false + : [/^http:\/\/localhost:\d+$/], }); // Security headers -- applied in all environments. HSTS is ignored over plain @@ -286,6 +288,7 @@ await app.register(rateLimit, { // Block TRACE method (returns 401 instead of 405 without this) app.addHook("onRequest", async (request, reply) => { if (request.method === "TRACE") { + reply.header("Allow", "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"); return reply.status(405).send({ error: "Method not allowed" }); } }); diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index da60f4b7..1c01fa5b 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -37,8 +37,10 @@ export async function isToolAuditEnabled(): Promise { } } +const AUDIT_STRIP_RE = /[<>&"'\r\n\0\x85\u2028\u2029]/g; + export function sanitizeAuditInput(raw: string): string { - return raw.replace(/[<>&"']/g, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)"; + return raw.replace(AUDIT_STRIP_RE, "").slice(0, MAX_AUDIT_INPUT_LENGTH) || "(empty)"; } /** diff --git a/apps/api/src/lib/browser-service.ts b/apps/api/src/lib/browser-service.ts index f60474d2..8b039596 100644 --- a/apps/api/src/lib/browser-service.ts +++ b/apps/api/src/lib/browser-service.ts @@ -1,6 +1,9 @@ +import { lookup } from "node:dns/promises"; import { existsSync } from "node:fs"; +import { isIP } from "node:net"; import PQueue from "p-queue"; -import { type Browser, chromium } from "playwright"; +import { type Browser, chromium, type Page } from "playwright"; +import { isPrivateIp } from "./ssrf.js"; const MAX_PAGES = Math.max(1, parseInt(process.env.BROWSER_MAX_PAGES || "3", 10)); const CRASH_WINDOW_MS = 60_000; @@ -56,6 +59,59 @@ function recordCrash(): void { backoffUntil = now + delay; } +async function isBlockedUrl(url: string): Promise { + try { + const parsed = new URL(url); + if ( + parsed.protocol === "data:" || + parsed.protocol === "blob:" || + parsed.protocol === "about:" + ) { + return false; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return true; + } + const hostname = parsed.hostname.replace(/^\[|]$/g, ""); + if (isIP(hostname)) { + return isPrivateIp(hostname); + } + try { + const result = await lookup(hostname, { all: true }); + const entries = Array.isArray(result) ? result : [result]; + return entries.some((e) => isPrivateIp(e.address)); + } catch { + return true; + } + } catch { + return true; + } +} + +async function installNetworkGuard(page: Page): Promise { + await page.route("**/*", async (route) => { + const url = route.request().url(); + if (await isBlockedUrl(url)) { + await route.abort("blockedbyclient").catch(() => {}); + return; + } + try { + const response = await route.fetch(); + const responseUrl = response.url(); + if (responseUrl !== url && (await isBlockedUrl(responseUrl))) { + await route.abort("blockedbyclient").catch(() => {}); + return; + } + await route.fulfill({ response }); + } catch { + await route.abort("failed").catch(() => {}); + } + }); + await page.routeWebSocket("**/*", (ws) => { + ws.close(); + }); +} + async function getBrowser(): Promise { if (browserFailed) { throw new Error("Browser service permanently disabled after repeated crashes"); @@ -67,7 +123,12 @@ async function getBrowser(): Promise { return browser; } browser = await chromium.launch({ - args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + args: [ + "--no-sandbox", + "--disable-gpu", + "--disable-dev-shm-usage", + "--disable-background-networking", + ], }); browser.on("disconnected", () => { browser = null; @@ -88,7 +149,9 @@ export async function capturePage(url: string, options: CaptureOptions): Promise const page = await b.newPage({ viewport: { width: options.viewportWidth, height: options.viewportHeight }, isMobile: options.isMobile, + serviceWorkers: "block", }); + await installNetworkGuard(page); try { await page.goto(url, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT }); @@ -138,7 +201,9 @@ export async function captureHtml(html: string, options: CaptureOptions): Promis const page = await b.newPage({ viewport: { width: options.viewportWidth, height: options.viewportHeight }, isMobile: options.isMobile, + serviceWorkers: "block", }); + await installNetworkGuard(page); try { await page.setContent(html, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT }); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 1df582e6..3485f635 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -46,6 +46,7 @@ const envSchema = z MAX_WORKER_THREADS: z.coerce.number().default(0), PROCESSING_TIMEOUT_S: z.coerce.number().default(0), MAX_PIPELINE_STEPS: z.coerce.number().default(20), + MAX_PIPELINE_STEP_PIXELS: z.coerce.number().default(67_108_864), MAX_CANVAS_PIXELS: z.coerce.number().default(0), MAX_SVG_SIZE_MB: z.coerce.number().default(50), MAX_SPLIT_GRID: z.coerce.number().default(100), diff --git a/apps/api/src/lib/ssrf.ts b/apps/api/src/lib/ssrf.ts index e3cacf6a..c60962ba 100644 --- a/apps/api/src/lib/ssrf.ts +++ b/apps/api/src/lib/ssrf.ts @@ -50,6 +50,10 @@ function isPrivateIPv6(ip: string): boolean { return false; } +export function isPrivateIp(ip: string): boolean { + return isPrivateIPv4(ip) || isPrivateIPv6(ip); +} + /** * Resolve a hostname and validate all returned IPs are public. * Returns the first valid resolved IP so callers can pin it for the actual @@ -58,7 +62,7 @@ function isPrivateIPv6(ip: string): boolean { async function resolveAndCheck(hostname: string): Promise { const bare = hostname.replace(/^\[|]$/g, ""); if (isIP(bare)) { - if (isPrivateIPv4(bare) || isPrivateIPv6(bare)) { + if (isPrivateIp(bare)) { throw new Error("URL resolves to a private or reserved IP address"); } return bare; @@ -67,8 +71,7 @@ async function resolveAndCheck(hostname: string): Promise { const result = await lookup(hostname, { all: true }); const addresses = Array.isArray(result) ? result : [result]; for (const entry of addresses) { - const addr = entry.address; - if (isPrivateIPv4(addr) || isPrivateIPv6(addr)) { + if (isPrivateIp(entry.address)) { throw new Error("URL resolves to a private or reserved IP address"); } } diff --git a/apps/api/src/lib/svg-sanitize.ts b/apps/api/src/lib/svg-sanitize.ts index 77f7ea7c..14d2cfcc 100644 --- a/apps/api/src/lib/svg-sanitize.ts +++ b/apps/api/src/lib/svg-sanitize.ts @@ -15,6 +15,8 @@ function decodeNumericEntities(input: string): string { * Sanitize an SVG buffer to prevent XXE, SSRF, and script injection. * Throws if the SVG exceeds the maximum allowed size. */ +const MAX_SVG_ELEMENTS = 5_000; + export function sanitizeSvg(buffer: Buffer): Buffer { const maxSvgSize = env.MAX_SVG_SIZE_MB > 0 ? env.MAX_SVG_SIZE_MB * 1024 * 1024 : Infinity; if (buffer.length > maxSvgSize) { @@ -25,6 +27,11 @@ export function sanitizeSvg(buffer: Buffer): Buffer { // ── Pre-processing: strip CDATA sections and decode numeric entities ── // CDATA sections can hide script content from regex-based checks. svg = svg.replace(//gi, ""); + + const elementCount = (svg.match(/<[a-zA-Z][^>]*\/?>/g) || []).length; + if (elementCount > MAX_SVG_ELEMENTS) { + throw new Error(`SVG exceeds maximum element count of ${MAX_SVG_ELEMENTS}`); + } // Decode numeric entities so obfuscated URIs (e.g. javascript:) are visible. svg = decodeNumericEntities(svg); diff --git a/apps/api/src/openapi.yaml b/apps/api/src/openapi.yaml index 66b57602..4050568a 100644 --- a/apps/api/src/openapi.yaml +++ b/apps/api/src/openapi.yaml @@ -5876,6 +5876,7 @@ paths: type: array items: type: string + nullable: true expiresAt: type: string format: date-time @@ -5923,6 +5924,7 @@ paths: type: array items: type: string + nullable: true createdAt: type: string format: date-time @@ -6739,19 +6741,26 @@ paths: type: string actorId: type: string + nullable: true actorUsername: type: string action: type: string targetType: type: string + nullable: true targetId: type: string + nullable: true details: type: object nullable: true ipAddress: type: string + nullable: true + requestId: + type: string + nullable: true createdAt: type: string format: date-time diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 8f3548f7..5b7339ff 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -413,6 +413,19 @@ export async function authRoutes(app: FastifyInstance): Promise { // MFA plugin not loaded } + const cookieReply = reply as FastifyReply & { + setCookie?: (name: string, value: string, opts: Record) => FastifyReply; + }; + if (typeof cookieReply.setCookie === "function") { + cookieReply.setCookie("snapotter-session", token, { + path: "/", + httpOnly: true, + sameSite: "strict", + secure: env.EXTERNAL_URL.startsWith("https"), + maxAge: SESSION_DURATION_MS / 1000, + }); + } + return reply.send({ token, user: { diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 748c0b53..673f12b3 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -387,6 +387,17 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise 0) { + const s = settingsResult.data as Record; + const w = Number(s.width) || 0; + const h = Number(s.height) || 0; + if (w > 0 && h > 0 && w * h > env.MAX_PIPELINE_STEP_PIXELS) { + return reply.status(400).send({ + error: `Step ${i + 1} (${step.toolId}): Output dimensions ${w}x${h} exceed per-step pixel limit`, + }); + } + } + parsedSteps.push({ toolId: step.toolId, resolvedToolId, @@ -787,6 +798,17 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise 0) { + const s = settingsResult.data as Record; + const w = Number(s.width) || 0; + const h = Number(s.height) || 0; + if (w > 0 && h > 0 && w * h > env.MAX_PIPELINE_STEP_PIXELS) { + return reply.status(400).send({ + error: `Step ${i + 1} (${step.toolId}): Output dimensions ${w}x${h} exceed per-step pixel limit`, + }); + } + } + parsedSteps.push({ toolId: step.toolId, resolvedToolId, diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 530f2961..b4188958 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -28,6 +28,10 @@ const SENSITIVE_KEYS = new Set([ "siem_webhook_auth", ]); +const REDACTED_KEYS = new Set(["cookie_secret", "oidc_client_secret", "siem_webhook_auth"]); + +const READONLY_KEYS = new Set(["cookie_secret", "instance_id"]); + async function encryptIfSensitive(key: string, value: string): Promise { if (!env.DATA_ENCRYPTION_KEY || !SENSITIVE_KEYS.has(key)) return value; return encrypt(value, env.DATA_ENCRYPTION_KEY); @@ -57,6 +61,10 @@ export async function settingsRoutes(app: FastifyInstance): Promise { const settings: Record = {}; for (const row of rows) { if (!isAdmin && SENSITIVE_KEYS.has(row.key)) continue; + if (REDACTED_KEYS.has(row.key)) { + settings[row.key] = "********"; + continue; + } settings[row.key] = await decryptIfNeeded(row.value); } @@ -92,6 +100,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise { }); } + if (READONLY_KEYS.has(key)) { + return reply.status(400).send({ + error: `Setting "${key}" cannot be modified via the API`, + code: "READONLY_SETTING", + }); + } + entries.push({ key, strValue }); } @@ -152,7 +167,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { return reply.send({ key: row.key, - value: await decryptIfNeeded(row.value), + value: REDACTED_KEYS.has(row.key) ? "********" : await decryptIfNeeded(row.value), updatedAt: row.updatedAt.toISOString(), }); }, diff --git a/tests/qa/tools-meta.json b/tests/qa/tools-meta.json index a3445c5a..4c930d41 100644 --- a/tests/qa/tools-meta.json +++ b/tests/qa/tools-meta.json @@ -858,7 +858,9 @@ "id": "ocr-pdf", "name": "PDF OCR", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "long", "isAI": true }, @@ -2620,7 +2622,10 @@ "id": "gif-webp", "name": "GIF/WebP Converter", "modality": "image", - "acceptedInputs": [".gif", ".webp"], + "acceptedInputs": [ + ".gif", + ".webp" + ], "executionHint": "fast", "isAI": false }, @@ -2763,7 +2768,10 @@ "id": "svg-to-raster", "name": "SVG to Raster", "modality": "image", - "acceptedInputs": [".svg", ".svgz"], + "acceptedInputs": [ + ".svg", + ".svgz" + ], "executionHint": "fast", "isAI": false }, @@ -2861,7 +2869,9 @@ "id": "pdf-to-image", "name": "PDF to Image", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3277,7 +3287,9 @@ "id": "gif-to-video", "name": "GIF to Video", "modality": "video", - "acceptedInputs": [".gif"], + "acceptedInputs": [ + ".gif" + ], "executionHint": "fast", "isAI": false }, @@ -3883,7 +3895,9 @@ "id": "merge-pdf", "name": "Merge PDFs", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3891,7 +3905,9 @@ "id": "split-pdf", "name": "Split PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3899,7 +3915,9 @@ "id": "compress-pdf", "name": "Compress PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3907,7 +3925,9 @@ "id": "rotate-pdf", "name": "Rotate PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3915,7 +3935,13 @@ "id": "convert-document", "name": "Convert Document", "modality": "document", - "acceptedInputs": [".docx", ".doc", ".odt", ".rtf", ".txt"], + "acceptedInputs": [ + ".docx", + ".doc", + ".odt", + ".rtf", + ".txt" + ], "executionHint": "long", "isAI": false }, @@ -3923,7 +3949,11 @@ "id": "convert-presentation", "name": "Convert Presentation", "modality": "document", - "acceptedInputs": [".pptx", ".ppt", ".odp"], + "acceptedInputs": [ + ".pptx", + ".ppt", + ".odp" + ], "executionHint": "long", "isAI": false }, @@ -3931,7 +3961,12 @@ "id": "convert-spreadsheet", "name": "Convert Spreadsheet", "modality": "document", - "acceptedInputs": [".xlsx", ".xls", ".ods", ".csv"], + "acceptedInputs": [ + ".xlsx", + ".xls", + ".ods", + ".csv" + ], "executionHint": "long", "isAI": false }, @@ -3939,7 +3974,12 @@ "id": "excel-to-pdf", "name": "Excel to PDF", "modality": "document", - "acceptedInputs": [".xlsx", ".xls", ".ods", ".csv"], + "acceptedInputs": [ + ".xlsx", + ".xls", + ".ods", + ".csv" + ], "executionHint": "long", "isAI": false }, @@ -3947,7 +3987,13 @@ "id": "word-to-pdf", "name": "Word to PDF", "modality": "document", - "acceptedInputs": [".docx", ".doc", ".odt", ".rtf", ".txt"], + "acceptedInputs": [ + ".docx", + ".doc", + ".odt", + ".rtf", + ".txt" + ], "executionHint": "long", "isAI": false }, @@ -3955,7 +4001,9 @@ "id": "extract-pages", "name": "Extract Pages", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3963,7 +4011,9 @@ "id": "remove-pages", "name": "Remove Pages", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3971,7 +4021,9 @@ "id": "organize-pdf", "name": "Organize PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3979,7 +4031,9 @@ "id": "protect-pdf", "name": "Protect PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3987,7 +4041,9 @@ "id": "unlock-pdf", "name": "Unlock PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -3995,7 +4051,9 @@ "id": "repair-pdf", "name": "Repair PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4003,7 +4061,9 @@ "id": "linearize-pdf", "name": "Web-Optimize PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4011,7 +4071,9 @@ "id": "grayscale-pdf", "name": "Grayscale PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4019,7 +4081,9 @@ "id": "pdfa-convert", "name": "PDF/A Convert", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4027,7 +4091,9 @@ "id": "crop-pdf", "name": "Crop PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4035,7 +4101,9 @@ "id": "nup-pdf", "name": "N-up PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4043,7 +4111,9 @@ "id": "booklet-pdf", "name": "Booklet PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4051,7 +4121,9 @@ "id": "watermark-pdf", "name": "Watermark PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4059,7 +4131,9 @@ "id": "pdf-page-numbers", "name": "PDF Page Numbers", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4067,7 +4141,9 @@ "id": "flatten-pdf", "name": "Flatten PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4075,7 +4151,9 @@ "id": "redact-pdf", "name": "Redact PDF", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4083,7 +4161,9 @@ "id": "pdf-to-text", "name": "PDF to Text", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4091,7 +4171,9 @@ "id": "pdf-to-word", "name": "PDF to Word", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "long", "isAI": false }, @@ -4099,7 +4181,9 @@ "id": "pdf-metadata", "name": "PDF Metadata", "modality": "document", - "acceptedInputs": [".pdf"], + "acceptedInputs": [ + ".pdf" + ], "executionHint": "fast", "isAI": false }, @@ -4107,7 +4191,11 @@ "id": "powerpoint-to-pdf", "name": "PowerPoint to PDF", "modality": "document", - "acceptedInputs": [".pptx", ".ppt", ".odp"], + "acceptedInputs": [ + ".pptx", + ".ppt", + ".odp" + ], "executionHint": "long", "isAI": false }, @@ -4115,7 +4203,10 @@ "id": "html-to-pdf", "name": "HTML to PDF", "modality": "document", - "acceptedInputs": [".html", ".htm"], + "acceptedInputs": [ + ".html", + ".htm" + ], "executionHint": "long", "isAI": false }, @@ -4123,7 +4214,10 @@ "id": "markdown-to-docx", "name": "Markdown to Word", "modality": "document", - "acceptedInputs": [".md", ".markdown"], + "acceptedInputs": [ + ".md", + ".markdown" + ], "executionHint": "fast", "isAI": false }, @@ -4131,7 +4225,10 @@ "id": "markdown-to-html", "name": "Markdown to HTML", "modality": "document", - "acceptedInputs": [".md", ".markdown"], + "acceptedInputs": [ + ".md", + ".markdown" + ], "executionHint": "fast", "isAI": false }, @@ -4139,7 +4236,10 @@ "id": "markdown-to-pdf", "name": "Markdown to PDF", "modality": "document", - "acceptedInputs": [".md", ".markdown"], + "acceptedInputs": [ + ".md", + ".markdown" + ], "executionHint": "long", "isAI": false }, @@ -4147,7 +4247,9 @@ "id": "epub-convert", "name": "Convert EPUB", "modality": "document", - "acceptedInputs": [".epub"], + "acceptedInputs": [ + ".epub" + ], "executionHint": "long", "isAI": false }, @@ -4155,7 +4257,12 @@ "id": "to-epub", "name": "Convert to EPUB", "modality": "document", - "acceptedInputs": [".docx", ".md", ".html", ".txt"], + "acceptedInputs": [ + ".docx", + ".md", + ".html", + ".txt" + ], "executionHint": "long", "isAI": false }, @@ -4163,7 +4270,10 @@ "id": "chart-maker", "name": "Chart Maker", "modality": "file", - "acceptedInputs": [".csv", ".json"], + "acceptedInputs": [ + ".csv", + ".json" + ], "executionHint": "fast", "isAI": false }, @@ -4171,7 +4281,11 @@ "id": "csv-excel", "name": "CSV to Excel", "modality": "file", - "acceptedInputs": [".csv", ".tsv", ".xlsx"], + "acceptedInputs": [ + ".csv", + ".tsv", + ".xlsx" + ], "executionHint": "fast", "isAI": false }, @@ -4179,7 +4293,11 @@ "id": "csv-json", "name": "CSV to JSON", "modality": "file", - "acceptedInputs": [".csv", ".tsv", ".json"], + "acceptedInputs": [ + ".csv", + ".tsv", + ".json" + ], "executionHint": "fast", "isAI": false }, @@ -4187,7 +4305,10 @@ "id": "json-xml", "name": "JSON to XML", "modality": "file", - "acceptedInputs": [".json", ".xml"], + "acceptedInputs": [ + ".json", + ".xml" + ], "executionHint": "fast", "isAI": false }, @@ -4195,7 +4316,10 @@ "id": "split-csv", "name": "Split CSV", "modality": "file", - "acceptedInputs": [".csv", ".tsv"], + "acceptedInputs": [ + ".csv", + ".tsv" + ], "executionHint": "fast", "isAI": false }, @@ -4203,7 +4327,10 @@ "id": "merge-csvs", "name": "Merge CSVs", "modality": "file", - "acceptedInputs": [".csv", ".tsv"], + "acceptedInputs": [ + ".csv", + ".tsv" + ], "executionHint": "fast", "isAI": false }, @@ -4211,7 +4338,11 @@ "id": "yaml-json", "name": "YAML / JSON", "modality": "file", - "acceptedInputs": [".yaml", ".yml", ".json"], + "acceptedInputs": [ + ".yaml", + ".yml", + ".json" + ], "executionHint": "fast", "isAI": false }, @@ -4219,7 +4350,9 @@ "id": "xml-to-csv", "name": "XML to CSV", "modality": "file", - "acceptedInputs": [".xml"], + "acceptedInputs": [ + ".xml" + ], "executionHint": "fast", "isAI": false }, @@ -4235,7 +4368,9 @@ "id": "extract-zip", "name": "Extract ZIP", "modality": "file", - "acceptedInputs": [".zip"], + "acceptedInputs": [ + ".zip" + ], "executionHint": "fast", "isAI": false }