/** * PingQL Query Engine — TypeScript implementation * * MongoDB-inspired query language for evaluating HTTP response conditions. * Mirrors the Rust implementation but also powers the visual query builder. */ // ── Types ────────────────────────────────────────────────────────────── export interface QueryField { name: string; description: string; type: "number" | "string" | "boolean" | "object"; operators: string[]; } export interface EvalContext { status: number; body: string; headers: Record; latency_ms?: number; cert_expiry_days?: number; } export interface ValidationError { path: string; message: string; } // ── Available fields ─────────────────────────────────────────────────── export function getAvailableFields(): QueryField[] { return [ { name: "status", description: "HTTP status code (e.g. 200, 404, 500)", type: "number", operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"], }, { name: "body", description: "Response body as text", type: "string", operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"], }, { name: "headers.*", description: "Response header value (e.g. headers.content-type)", type: "string", operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"], }, { name: "$select", description: "CSS selector — returns text content of first matching element", type: "string", operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"], }, { name: "$json", description: "JSONPath expression evaluated against response body (e.g. $.data.status)", type: "string", operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"], }, { name: "$responseTime", description: "Request latency in milliseconds", type: "number", operators: ["$eq", "$gt", "$gte", "$lt", "$lte"], }, { name: "$certExpiry", description: "Days until SSL certificate expires", type: "number", operators: ["$eq", "$gt", "$gte", "$lt", "$lte"], }, ]; } // ── Evaluate ─────────────────────────────────────────────────────────── export function evaluate(query: unknown, ctx: EvalContext): boolean { if (query === null || query === undefined) return true; if (typeof query !== "object" || Array.isArray(query)) { throw new Error("Query must be an object"); } const q = query as Record; // $consider — "up" (default) or "down": flips result if conditions match if ("$consider" in q) { const consider = q.$consider as string; const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider")); const matches = evaluate(rest, ctx); return consider === "down" ? !matches : matches; } // $and if ("$and" in q) { const clauses = q.$and; if (!Array.isArray(clauses)) throw new Error("$and expects array"); return clauses.every((c) => evaluate(c, ctx)); } // $or if ("$or" in q) { const clauses = q.$or; if (!Array.isArray(clauses)) throw new Error("$or expects array"); return clauses.some((c) => evaluate(c, ctx)); } // $not if ("$not" in q) { return !evaluate(q.$not, ctx); } // $responseTime if ("$responseTime" in q) { const val = ctx.latency_ms ?? 0; return evalCondition(q.$responseTime, val); } // $certExpiry if ("$certExpiry" in q) { const val = ctx.cert_expiry_days ?? Infinity; return evalCondition(q.$certExpiry, val); } // $select — { "$select": { "css.selector": { "$op": val } } } if ("$select" in q) { // Server-side: no DOM parser available, pass through (Rust runner evaluates) // Validate structure only const selMap = q.$select as Record; if (typeof selMap !== "object" || Array.isArray(selMap)) throw new Error("$select expects { selector: condition }"); return true; } // $json — { "$json": { "$.path": { "$op": val } } } if ("$json" in q) { const pathMap = q.$json as Record; if (typeof pathMap !== "object" || Array.isArray(pathMap)) throw new Error("$json expects { path: condition }"); for (const [path, condition] of Object.entries(pathMap)) { const resolved = resolveJsonPath(ctx.body, path); if (!evalCondition(condition, resolved)) return false; } return true; } // Field-level checks for (const [field, condition] of Object.entries(q)) { if (field.startsWith("$")) continue; // skip unknown $ ops const fieldVal = resolveField(field, ctx); if (!evalCondition(condition, fieldVal)) return false; } return true; } function resolveField(field: string, ctx: EvalContext): unknown { switch (field) { case "status": case "status_code": return ctx.status; case "body": return ctx.body; default: if (field.startsWith("headers.")) { const key = field.slice(8).toLowerCase(); return ctx.headers[key] ?? null; } return null; } } function resolveJsonPath(body: string, expr: string): unknown { try { const obj = JSON.parse(body); // Simple dot-notation JSONPath: $.foo.bar[0].baz const path = expr.replace(/^\$\.?/, ""); if (!path) return obj; const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean); let current: unknown = obj; for (const part of parts) { if (current === null || current === undefined) return null; current = (current as Record)[part]; } return current ?? null; } catch { return null; } } function evalCondition(condition: unknown, fieldVal: unknown): boolean { if (condition === null || condition === undefined) return true; // Direct equality shorthand: { "status": 200 } if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") { return fieldVal === condition; } if (typeof condition === "object" && !Array.isArray(condition)) { const ops = condition as Record; for (const [op, opVal] of Object.entries(ops)) { if (!evalOp(op, fieldVal, opVal)) return false; } return true; } return true; } function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean { switch (op) { case "$eq": return fieldVal === opVal; case "$ne": return fieldVal !== opVal; case "$gt": return toNum(fieldVal) > toNum(opVal); case "$gte": return toNum(fieldVal) >= toNum(opVal); case "$lt": return toNum(fieldVal) < toNum(opVal); case "$lte": return toNum(fieldVal) <= toNum(opVal); case "$contains": return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal); case "$startsWith": return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal); case "$endsWith": return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal); case "$regex": { if (typeof fieldVal !== "string" || typeof opVal !== "string") return false; if (opVal.length > 200) return false; if (isSafeRegex(opVal) === false) return false; try { return new RegExp(opVal).test(fieldVal); } catch { return false; } } case "$exists": return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined; case "$in": return Array.isArray(opVal) && opVal.includes(fieldVal); default: return true; // unknown op — skip } } function toNum(v: unknown): number { return typeof v === "number" ? v : Number(v) || 0; } /** * Reject regex patterns likely to cause catastrophic backtracking (ReDoS). * Blocks nested quantifiers like (a+)+ and star-height > 1 patterns. */ function isSafeRegex(pattern: string): boolean { // Reject nested quantifiers: (x+)+, (x*)+, (x+)*, (x{n,})+, etc. if (/\([^)]*[+*}]\)[+*{]/.test(pattern)) return false; // Reject overlapping alternation with quantifiers: (a|a)+ if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false; return true; } // ── Validate ─────────────────────────────────────────────────────────── const VALID_OPS = new Set([ "$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$startsWith", "$endsWith", "$regex", "$exists", "$in", "$select", "$json", "$and", "$or", "$not", "$responseTime", "$certExpiry", ]); const VALID_FIELDS = new Set([ "status", "status_code", "body", ]); export function validateQuery(query: unknown, path = ""): ValidationError[] { const errors: ValidationError[] = []; if (query === null || query === undefined) return errors; if (typeof query !== "object" || Array.isArray(query)) { errors.push({ path: path || "$", message: "Query must be an object" }); return errors; } const q = query as Record; for (const [key, value] of Object.entries(q)) { const keyPath = path ? `${path}.${key}` : key; if (key === "$and" || key === "$or") { if (!Array.isArray(value)) { errors.push({ path: keyPath, message: `${key} expects an array` }); } else { value.forEach((clause, i) => { errors.push(...validateQuery(clause, `${keyPath}[${i}]`)); }); } } else if (key === "$not") { errors.push(...validateQuery(value, keyPath)); } else if (key === "$responseTime" || key === "$certExpiry") { if (typeof value !== "object" || value === null || Array.isArray(value)) { errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` }); } else { for (const op of Object.keys(value as Record)) { if (!VALID_OPS.has(op)) { errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` }); } } } } else if (key === "$select" || key === "$json") { if (typeof value !== "string") { errors.push({ path: keyPath, message: `${key} expects a string` }); } } else if (key === "$consider") { if (value !== "up" && value !== "down") { errors.push({ path: keyPath, message: '$consider must be "up" or "down"' }); } } else if (key.startsWith("$")) { // It's an operator inside a field condition — skip validation here } else { // Field name if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) { errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` }); } if (typeof value === "object" && value !== null && !Array.isArray(value)) { const ops = value as Record; for (const op of Object.keys(ops)) { if (!op.startsWith("$")) continue; if (!VALID_OPS.has(op)) { errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` }); } if (op === "$regex" && typeof ops[op] === "string" && (ops[op] as string).length > 200) { errors.push({ path: `${keyPath}.${op}`, message: "Regex pattern too long (max 200 characters)" }); } } } } } return errors; }