348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
/**
|
|
* 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<string, string>;
|
|
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<string, unknown>;
|
|
|
|
// $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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>)[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<string, unknown>;
|
|
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<string, unknown>;
|
|
|
|
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<string, unknown>)) {
|
|
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<string, unknown>;
|
|
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;
|
|
}
|