Custom Rules.
Define your own deterministic AST conditions to block domain-specific queries that standard rules do not cover.
What are custom rules?
Vericto's standard rules cover the most common destructive patterns (DELETE without WHERE, DROP TABLE, TRUNCATE, etc.). Custom rules let you extend that ruleset with conditions specific to your business, defined through a YAML condition over the query's AST syntax tree.
Like all Vericto rules, custom rules are deterministic: the same input always produces the same result. There are no ML models, no variance. A query either meets the condition or it doesn't, no ambiguity.
Common use cases:
- Block
SELECT *(bulk exfiltration). A SELECT cannot be scoped to one table; to protect specific columns, tag them in Sensitive columns - Prevent
UPDATEwithout a WHERE clause on high-criticality tables - Detect calls to dangerous functions like
SLEEP()orPG_SLEEP() - Block
TRUNCATEandDROP TABLEat runtime, outside migrations - Scope a
DELETEorUPDATEwithout WHERE to one critical table withrelation
Availability by plan
Custom rules are available starting with the BUILDER plan ($79/mo).
| Plan | Custom rules | Limit |
|---|---|---|
| Free | Not available | — |
| Builder ($79/mo) | ✅ Included | Up to 5 custom rules |
| Team ($249/mo) | ✅ Included | Up to 20 custom rules |
If you try to create a custom rule on the Free plan, the API responds with 403 Forbidden (code PLAN_UPGRADE_REQUIRED) and an upgrade link. At your plan's limit the same response carries reason: "custom_rule_limit".
YAML schema
Each custom rule carries a YAML condition that defines when it fires. The only required field is node_type; rule is an optional label.
rule: nombre-interno-de-la-regla # identificador libre
node_type: DeleteStmt # tipo de nodo AST a capturar
condition:
where_clause: null # solo si falta WHERE
YAML fields
Internal label for whoever reads the YAML. The engine accepts it but does not use it: what identifies the rule in logs and the audit trail is its code (CUSTOM-001) and the name you gave it on creation. Use kebab-case, e.g.: block-delete-on-payments.
AST node type that triggers the condition. See available node types.
Predicates that narrow the node down. There are eight, and all of them go inside condition, never at the top level: relation, where_clause, where_always_true, target_list, has_limit, func_name, object_type and alter_kind. Which ones apply depends on the node_type; the detail and the valid values are in available conditions. If you omit condition, the rule fires for every node of the given node_type.
Reserved. The engine accepts the field but does not surface it yet: the block response is built from the rule code and the AST node path. To make a block self-explanatory, put the text in the rule name, which does appear in the dashboard and the audit trail.
AST node types
AST nodes represent the type of SQL statement or expression that Vericto detects in the syntax tree. The dialect determines which parser is used (pg_query for PostgreSQL, sqlparser-rs for MySQL).
| node_type | Description | Dialect |
|---|---|---|
DeleteStmt | DELETE statement | postgres / all |
UpdateStmt | UPDATE statement | postgres / all |
SelectStmt | SELECT statement | postgres / all |
InsertStmt | INSERT statement | postgres / all |
DropStmt | DROP statement (TABLE, DATABASE, INDEX, SCHEMA) | postgres / all |
TruncateStmt | TRUNCATE statement | postgres |
AlterTableStmt | ALTER TABLE (DROP COLUMN, RENAME, etc.) | postgres |
FuncCall | SQL function call (SLEEP, PG_SLEEP, etc.) | postgres / all |
Available conditions
Inside condition: you can use the following predicates, grouped by the node type they apply to. Combining them matters: a predicate the node never carries would make the rule never fire, so Vericto rejects that combination when you save or preview (error NEVER_FIRES, with the line), in the dashboard and in the API. Example: target_list: "*" with relation on a SelectStmt.
For DELETE / UPDATE / INSERT / ALTER TABLE
| Predicate | Value | Meaning |
|---|---|---|
relation: "nombre" | string | Scopes the rule to one table (case-insensitive; ignores the schema, so public.payments matches payments). Only works with DELETE, UPDATE, INSERT and ALTER TABLE. It does not work with SelectStmt or FuncCall on any dialect, and with TruncateStmt and DropStmt it only works on MySQL, Oracle and SQL Server, not PostgreSQL. Vericto rejects a rule that can never fire, and warns when with dialect "All" it only fires on some. To protect a table's columns on reads, use Sensitive columns. |
For DELETE / UPDATE / SELECT
| Predicate | Value | Meaning |
|---|---|---|
where_clause: null | null | The query has NO WHERE clause |
where_always_true: true | true | Trivially true WHERE (1=1, true) |
For SELECT
| Predicate | Value | Meaning |
|---|---|---|
target_list: "*" | "*" | The query uses SELECT * |
has_limit: false | false | The query does not include LIMIT |
For FuncCall
| Predicate | Value | Meaning |
|---|---|---|
func_name: "nombre" | string | Name of the function to block (case-insensitive) |
For DropStmt
| Predicate | Value | Meaning |
|---|---|---|
object_type: "table" | "table", "database", "schema", "index" | Type of object being dropped |
For AlterTableStmt
| Predicate | Value | Meaning |
|---|---|---|
alter_kind: "drop_column" | "drop_column", "rename", "drop_constraint", "alter_column_type", "disable_trigger" | ALTER TABLE command subtype. Without this predicate, the rule fires for any ALTER TABLE. |
Practical examples
1. Block SELECT * (mass exfiltration)
rule: block-select-star
node_type: SelectStmt
condition:
target_list: "*"
Blocks queries like SELECT * FROM users or SELECT * FROM payments. Allows queries with explicit columns like SELECT id, email FROM users.
2. Block UPDATE without WHERE
rule: block-update-no-where
node_type: UpdateStmt
condition:
where_clause: null
Blocks UPDATE users SET status = 'blocked' but allows UPDATE users SET status = 'blocked' WHERE id = $1.
3. Detect SQL injection tautologies
rule: block-or-tautology
node_type: DeleteStmt
condition:
where_always_true: true
Blocks queries like DELETE FROM users WHERE id = 1 OR 1=1 by detecting the trivially true OR branch in the AST.
4. Block SLEEP calls (DoS) — one rule per dialect
# MySQL
rule: block-sleep-calls
node_type: FuncCall
condition:
func_name: sleep
# PostgreSQL — la función tiene otro nombre
rule: block-pg-sleep-calls
node_type: FuncCall
condition:
func_name: pg_sleep
func_name matches the exact function name (case-insensitive), not a family. MySQL calls the function SLEEP and PostgreSQL calls it PG_SLEEP, so one rule does not cover both: func_name: sleep lets SELECT pg_sleep(10) through silently. Verified against the proxy. To cover both, create both rules. The built-in VERICTO-070 does cover the whole family, because it is implemented in the engine rather than as a predicate.
5. Block SELECT without LIMIT
rule: require-limit-on-select
node_type: SelectStmt
condition:
has_limit: false
message: "Las queries SELECT deben incluir cláusula LIMIT para evitar scans completos de tabla"
Useful for very large tables where a full scan without a limit can significantly degrade performance.
6. Block DROP of any object
rule: block-all-drops
node_type: DropStmt
Without condition:, the rule triggers for any DROP (TABLE, INDEX, SCHEMA). Useful in production environments where no DROP should run from the application.
7. Protect a specific table
rule: protect-payments
node_type: DeleteStmt
condition:
relation: payments
where_clause: null
With relation you scope the rule to a single table: it blocks DELETE FROM payments without WHERE, but leaves other tables untouched. Combine any predicate with relation to limit its scope.
8. Block DROP COLUMN
rule: block-drop-column
node_type: AlterTableStmt
condition:
alter_kind: drop_column
Blocks ALTER TABLE users DROP COLUMN email (irreversible data loss) but allows other changes like RENAME or ADD COLUMN.
Monitor first, then enforce
A new custom rule starts in monitor mode: the engine evaluates it and records its matches in the audit trail, but it never blocks. When you trust it, open it in Rules and use Promote to enforce: the confirmation says what the rule will do in your workspace (for example BLOCK) and links its latest simulation, or warns you when you did not simulate it, when the simulation failed or when it found legitimate flows that would break. You can go back to monitor at any time.
How it works: the engine has no per-rule action, it resolves every match through the policy's severity → action map. So a monitor rule is sent to the engine with the mildest severity of your policy that does not block (Monitor or, if there is none, Flag). Its events show that severity; the rule keeps its own, which is what promoting it puts into force. If every severity of your policy blocks, a monitor rule is paused (not evaluated) and the dashboard says so.
Rules that existed before this change stay enforced, exactly as they were: no rule in production changed behaviour. In the API, POST /rules/custom accepts "mode": "enforce" if you want a rule enforced from the start, and PATCH /rules/custom/:rule_id with {"mode": "enforce"} promotes it; the change is recorded in the audit log as rule.mode.changed.
To edit a rule, open it: you see its condition, its history and its latest simulation. While you edit, the condition is checked as you type, and before saving you see a side-by-side diff against the saved version.
Preview before activating
Before saving a custom rule, the dashboard offers a preview that evaluates the YAML against the workspace's historical queries from the last 7 days.
The preview shows:
- Whether the YAML is valid (
validation_result.valid: true/false) - How many historical queries this rule would have blocked
- An example query that triggers the condition
- List of matching audit trail events
The preview is read-only: it blocks nothing. Creating the rule does not block yet either: a new rule starts in monitor mode and only blocks once you promote it to enforce.
For a change with more at stake, the rule simulator replays your workspace's traffic (up to 30 days on Builder, 90 on Team and per contract (up to 180) on Enterprise; you choose the window on each run) with the new or edited rules, compares them with the current ones and separates the legitimate flows that would break. From the dashboard or from CI, on Builder plans and above.
REST API for custom rules
Create a custom rule
POST /api/v1/workspaces/:workspace_id/rules/custom
Authorization: Bearer <token>
{
"name": "Bloquear SELECT * (cualquier tabla)",
"description": "Obliga a listar columnas explícitas; evita exfiltración masiva",
"severity": "high",
"dialect": "postgres",
"ast_condition_yaml": "rule: block-select-star\nnode_type: SelectStmt\ncondition:\n target_list: \"*\"",
"mode": "monitor"
}
// Response 201
{
"rule_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"code": "CUSTOM-001",
"name": "Bloquear SELECT * (cualquier tabla)",
"severity": "high",
"is_active": true,
"mode": "monitor",
"created_at": "2025-06-20T10:00:00Z",
"validation_result": { "valid": true, "errors": [] }
}
// Promote it to enforce
PATCH /api/v1/workspaces/:workspace_id/rules/custom/:rule_id
{ "mode": "enforce" }
// A condition that can never fire: 400
{
"error": {
"code": "INVALID_RULE_CONDITION",
"details": [
{ "field": "ast_condition_yaml", "code": "NEVER_FIRES", "line": 4,
"params": { "key": "relation", "node_type": "SelectStmt" },
"suggestion": "sensitive_columns" }
]
}
}
Preview before saving
POST /api/v1/workspaces/:workspace_id/rules/custom/preview
Authorization: Bearer <token>
{
"ast_condition_yaml": "rule: block-select-star\nnode_type: SelectStmt\ncondition:\n target_list: \"*\"",
"dialect": "postgres",
"lookback_days": 7
}
// Response 200
{
"matching_queries": [...],
"total_matches": 3,
"validation_result": { "valid": true, "errors": [] },
"example_triggering_query": "SELECT * FROM users"
}
Required fields
| Field | Type | Description |
|---|---|---|
name | string (1–128 chars) | Descriptive name of the rule. This is what the dashboard and the audit trail show when the rule blocks a query. |
description | string (max. 1024 chars, optional) | Free-text rationale shown on the rule card |
severity | "critical" / "high" / "medium" / "low" / "informational" | Severity. Once the rule is enforced, the workspace enforcement policy decides whether it blocks, flags or only monitors. Any other value returns 400. |
dialect | "postgres" / "mysql" / "oracle" / "mssql" / "all" | SQL dialect the rule applies to. "all" is currently stored as postgres. |
mode | "monitor" / "enforce" | Optional. Without it, the rule is created in monitor: it records its matches and blocks nothing until you promote it. |
ast_condition_yaml | string YAML (10–16384 chars) | AST condition that defines when to block |
Current limitations
- YAML conditions only support the predicates documented above: you can't write arbitrary expressions over the AST yet.
- A
SELECTcannot be scoped to one table: the engine records no table for a SELECT. For personal data, tag the columns in Sensitive columns. - Maximum of 5 custom rules on the Builder plan, 20 on Team and unlimited on Enterprise.
- The condition is validated on the server (and in the dashboard as you type): malformed YAML, an unknown predicate or a combination that never fires returns
400with codeINVALID_RULE_CONDITIONand, indetails, each problem with its code and line. - Custom rules don't support data-modifying CTEs yet (e.g.:
WITH x AS (DELETE ...)). Standard rules VERICTO-001 through VERICTO-020 do detect them.
Before you block with a custom rule, leave it in monitor mode and simulate it against production (Simulate against production in the editor or in the rule's details): the simulation replays your real traffic and separates the legitimate flows that would break. Promote it to enforce once the result is clean.
Do you have a use case the current conditions don't cover? Write to us. Real customer cases guide the roadmap for new predicates.