289 lines
9.1 KiB
Python
289 lines
9.1 KiB
Python
"""Segurança wizard VM112 — telemetria Spec 021."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
SECURITY_SOURCE = "vm112-security"
|
|
SECURITY_PREFIX = "security."
|
|
VM112_TENANT_ID = 1
|
|
|
|
AUTO_TICKET_EVENTS = frozenset({
|
|
"security.input_blocked",
|
|
"security.handoff_rejected",
|
|
"security.session_anomaly",
|
|
})
|
|
|
|
SEVERITY_BY_EVENT = {
|
|
"security.csp_violation": "warn",
|
|
"security.input_warn": "info",
|
|
"security.input_blocked": "high",
|
|
"security.rate_limited": "warn",
|
|
"security.handoff_created": "info",
|
|
"security.handoff_consumed": "info",
|
|
"security.handoff_rejected": "high",
|
|
"security.handoff_expired": "info",
|
|
"security.auth_failed": "warn",
|
|
"security.session_anomaly": "high",
|
|
}
|
|
|
|
FORBIDDEN_PAYLOAD_KEYS = frozenset({
|
|
"password",
|
|
"root_password",
|
|
"new_password",
|
|
"current_password",
|
|
"handoff_token",
|
|
"token",
|
|
"secret",
|
|
})
|
|
|
|
SQLI_PATTERNS = [
|
|
re.compile(r"'\s*or\s+", re.I),
|
|
re.compile(r"union\s+select", re.I),
|
|
re.compile(r";\s*drop\s+", re.I),
|
|
re.compile(r"1\s*=\s*1", re.I),
|
|
re.compile(r"--\s*$"),
|
|
]
|
|
|
|
XSS_PATTERNS = [
|
|
re.compile(r"<\s*script", re.I),
|
|
re.compile(r"javascript\s*:", re.I),
|
|
re.compile(r"onerror\s*=", re.I),
|
|
re.compile(r"onload\s*=", re.I),
|
|
]
|
|
|
|
PATH_PATTERNS = [
|
|
re.compile(r"\.\./"),
|
|
re.compile(r"%2e%2e", re.I),
|
|
]
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _parse_payload(raw: str | None) -> dict:
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def _scrub_data(data: dict | None) -> dict:
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
out: dict[str, Any] = {}
|
|
for key, val in data.items():
|
|
if key.lower() in FORBIDDEN_PAYLOAD_KEYS:
|
|
continue
|
|
if isinstance(val, str) and len(val) > 500:
|
|
out[key] = val[:500] + "…"
|
|
else:
|
|
out[key] = val
|
|
return out
|
|
|
|
|
|
def is_security_event(event: str) -> bool:
|
|
return bool(event) and event.startswith(SECURITY_PREFIX)
|
|
|
|
|
|
def audit_field_value(value: str, *, field: str = "") -> dict[str, Any]:
|
|
"""Heurística local (VM122) — espelho do middleware VM112."""
|
|
text = (value or "").strip()
|
|
if not text:
|
|
return {"ok": True}
|
|
if len(text) > 2000:
|
|
return {"ok": False, "reason": "oversize", "pattern_id": "field_too_long", "severity": "high"}
|
|
for pat in SQLI_PATTERNS:
|
|
if pat.search(text):
|
|
return {"ok": False, "reason": "sql_injection_pattern", "pattern_id": pat.pattern[:40], "severity": "high"}
|
|
for pat in XSS_PATTERNS:
|
|
if pat.search(text):
|
|
return {"ok": False, "reason": "xss_pattern", "pattern_id": pat.pattern[:40], "severity": "high"}
|
|
for pat in PATH_PATTERNS:
|
|
if pat.search(text):
|
|
return {"ok": False, "reason": "path_traversal", "pattern_id": pat.pattern[:40], "severity": "high"}
|
|
return {"ok": True}
|
|
|
|
|
|
def _enrich_row(row) -> dict[str, Any]:
|
|
payload = _parse_payload(row["payload"])
|
|
data = payload.get("data") or {}
|
|
return {
|
|
"id": row["id"],
|
|
"event_type": row["event_type"],
|
|
"source": row["source"],
|
|
"created_at": row["created_at"],
|
|
"session_id": payload.get("session_id"),
|
|
"domain": payload.get("domain"),
|
|
"severity": data.get("severity") or SEVERITY_BY_EVENT.get(row["event_type"], "info"),
|
|
"client_ip": data.get("client_ip") or payload.get("ingress_client_ip"),
|
|
"endpoint": data.get("endpoint"),
|
|
"reason": data.get("reason"),
|
|
"payload": payload,
|
|
}
|
|
|
|
|
|
def ingest_event(
|
|
conn,
|
|
*,
|
|
event: str,
|
|
session_id: str | None = None,
|
|
domain: str | None = None,
|
|
data: dict | None = None,
|
|
client_ip: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if not is_security_event(event):
|
|
raise ValueError(f"not a security event: {event}")
|
|
now = _now()
|
|
clean_data = _scrub_data(data)
|
|
if client_ip and not clean_data.get("client_ip"):
|
|
clean_data["client_ip"] = client_ip
|
|
if "severity" not in clean_data:
|
|
clean_data["severity"] = SEVERITY_BY_EVENT.get(event, "info")
|
|
stored = {
|
|
"event": event,
|
|
"source": SECURITY_SOURCE,
|
|
"session_id": session_id,
|
|
"domain": domain,
|
|
"data": clean_data,
|
|
}
|
|
if client_ip:
|
|
stored["ingress_client_ip"] = client_ip
|
|
payload = json.dumps(stored, ensure_ascii=False)
|
|
cur = conn.execute(
|
|
"INSERT INTO webhook_events (event_type, source, payload, created_at) VALUES (?,?,?,?)",
|
|
(event, SECURITY_SOURCE, payload, now),
|
|
)
|
|
event_id = int(cur.lastrowid)
|
|
ticket_id = None
|
|
if event in AUTO_TICKET_EVENTS:
|
|
domain_label = domain or "sem domínio"
|
|
subject = f"[security] {domain_label} — {event.replace('security.', '')}"
|
|
cur2 = conn.execute(
|
|
"""
|
|
INSERT INTO tickets (tenant_id, subject, status, payload, created_at, session_id)
|
|
VALUES (?, ?, 'escalated', ?, ?, ?)
|
|
""",
|
|
(VM112_TENANT_ID, subject, payload, now, session_id),
|
|
)
|
|
ticket_id = int(cur2.lastrowid)
|
|
conn.commit()
|
|
return {
|
|
"accepted": True,
|
|
"event_id": event_id,
|
|
"event": event,
|
|
"ticket_id": ticket_id,
|
|
}
|
|
|
|
|
|
def ingest_csp_report(conn, body: dict, client_ip: str | None = None) -> dict[str, Any]:
|
|
report = body.get("csp-report") or body.get("csp_report") or body
|
|
if not isinstance(report, dict):
|
|
report = {}
|
|
data = {
|
|
"document_uri": report.get("document-uri") or report.get("document_uri"),
|
|
"violated_directive": report.get("violated-directive") or report.get("violated_directive"),
|
|
"blocked_uri": report.get("blocked-uri") or report.get("blocked_uri"),
|
|
"source_file": report.get("source-file") or report.get("source_file"),
|
|
"line_number": report.get("line-number") or report.get("line_number"),
|
|
"severity": "warn",
|
|
"client_ip": client_ip,
|
|
}
|
|
return ingest_event(
|
|
conn,
|
|
event="security.csp_violation",
|
|
data=data,
|
|
client_ip=client_ip,
|
|
)
|
|
|
|
|
|
def build_summary(conn, *, window_hours: int = 24) -> dict[str, Any]:
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat()
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT event_type, payload, created_at
|
|
FROM webhook_events
|
|
WHERE source = ? AND created_at >= ?
|
|
ORDER BY id DESC
|
|
""",
|
|
(SECURITY_SOURCE, cutoff),
|
|
).fetchall()
|
|
counts: dict[str, int] = {}
|
|
sessions: set[str] = set()
|
|
for row in rows:
|
|
counts[row["event_type"]] = counts.get(row["event_type"], 0) + 1
|
|
p = _parse_payload(row["payload"])
|
|
sid = (p.get("session_id") or "").strip()
|
|
if sid:
|
|
sessions.add(sid)
|
|
recent = list_events(conn, limit=8, offset=0, window_hours=window_hours)["events"]
|
|
return {
|
|
"window_hours": window_hours,
|
|
"total": len(rows),
|
|
"csp_violations": counts.get("security.csp_violation", 0),
|
|
"inputs_blocked": counts.get("security.input_blocked", 0),
|
|
"inputs_warn": counts.get("security.input_warn", 0),
|
|
"handoffs_rejected": counts.get("security.handoff_rejected", 0),
|
|
"rate_limited": counts.get("security.rate_limited", 0),
|
|
"sessions_with_alerts": len(sessions),
|
|
"by_event": counts,
|
|
"recent": recent,
|
|
"enabled": True,
|
|
}
|
|
|
|
|
|
def list_events(
|
|
conn,
|
|
*,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
window_hours: int = 168,
|
|
session_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
limit = max(1, min(int(limit), 500))
|
|
offset = max(0, int(offset))
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat()
|
|
if session_id:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT id, event_type, source, payload, created_at
|
|
FROM webhook_events
|
|
WHERE source = ? AND created_at >= ? AND payload LIKE ?
|
|
ORDER BY id DESC LIMIT ? OFFSET ?
|
|
""",
|
|
(SECURITY_SOURCE, cutoff, f'%"{session_id}"%', limit, offset),
|
|
).fetchall()
|
|
total = conn.execute(
|
|
"""
|
|
SELECT COUNT(*) FROM webhook_events
|
|
WHERE source = ? AND created_at >= ? AND payload LIKE ?
|
|
""",
|
|
(SECURITY_SOURCE, cutoff, f'%"{session_id}"%',),
|
|
).fetchone()[0]
|
|
else:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT id, event_type, source, payload, created_at
|
|
FROM webhook_events
|
|
WHERE source = ? AND created_at >= ?
|
|
ORDER BY id DESC LIMIT ? OFFSET ?
|
|
""",
|
|
(SECURITY_SOURCE, cutoff, limit, offset),
|
|
).fetchall()
|
|
total = conn.execute(
|
|
"SELECT COUNT(*) FROM webhook_events WHERE source = ? AND created_at >= ?",
|
|
(SECURITY_SOURCE, cutoff),
|
|
).fetchone()[0]
|
|
return {
|
|
"events": [_enrich_row(r) for r in rows],
|
|
"total": int(total),
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"window_hours": window_hours,
|
|
}
|