MetricStream · feat: add user-defined alerting thresholds with DB persistence · Autor: Carlos Pérez · 18 Jun 2026
El metric_name proveniente del usuario se interpola directamente en la query SQL
con format!(). Cualquier usuario puede ejecutar SQL arbitrario contra TimescaleDB.
La suite de tests tiene un caso específico que falla sobre esto (test_alert_query_injection).
67- let query = format!( 68- "SELECT * FROM alerts WHERE metric_name = '{}'", 69- metric_name // ← user-controlled input sin sanitizar 70- ); 71- sqlx::query(&query).fetch_all(&*pool).await.unwrap()
67+ sqlx::query_as::<_, Alert>( 68+ "SELECT * FROM alerts WHERE metric_name = $1" 69+ ) 70+ .bind(metric_name) 71+ .fetch_all(&*pool) 72+ .await 73+ .context("failed to fetch alerts for metric")?
sqlx::query_as y .bind(). Nunca interpolación de strings en SQL.
Bloque unsafe que dereference un puntero crudo sin ningún comentario
// SAFETY: que documente los invariantes. Es imposible auditar si la operación
es segura y Clippy lo marca como error con -D warnings.
112- unsafe { 113- let val = *raw_ptr; 114- process_threshold(val); 115- }
112+ // SAFETY: `raw_ptr` es no-nulo y apunta a un ThresholdConfig que vive 113+ // al menos durante la duración de esta función (garantizado por el caller 114+ // en AlertManager::process, que mantiene ownership del Config). 115+ let val = unsafe { *raw_ptr }; 116+ process_threshold(val);
// SAFETY: exhaustivo antes de todo bloque unsafe.
Si no puedes justificarlo, refactoriza para eliminar el unsafe.
Múltiples llamadas a .unwrap() sobre Result de operaciones de base de datos
y de parse. Una pérdida de conexión o un valor malformado derriba el proceso completo.
38- let threshold: f64 = value.parse().unwrap(); 39- let saved = db::save_alert(&pool, threshold).await.unwrap();
38+ let threshold: f64 = value.parse() 39+ .map_err(|e| AppError::InvalidThreshold(e.to_string()))?; 40+ let saved = db::save_alert(&pool, threshold).await 41+ .context("failed to persist alert threshold")?;
? con el operador de propagación y .context() de anyhow
(o map_err con thiserror) en toda ruta de producción.
Se usa tokio::sync::mpsc::unbounded_channel() para pasar métricas al procesador.
Con un pico de tráfico, la cola crece sin límite y puede agotar la RAM del contenedor.
22- let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Metric>();
22+ // Bounded: aplica back-pressure al productor antes de OOM 23+ let (tx, rx) = tokio::sync::mpsc::channel::<Metric>(1_024);
std::thread::sleep(Duration::from_millis(500)) dentro de una función async
bloquea el thread de tokio, impidiendo que otras tareas progresen en ese worker.
89- std::thread::sleep(Duration::from_millis(500)); // "debounce" artesanal
89+ tokio::time::sleep(Duration::from_millis(500)).await; // yield al runtime
Funciones públicas que reciben String obligando al caller a ceder ownership
(o a clonar). Aceptar &str o impl AsRef<str> es más idiomático
y evita copias.
15-pub fn validate_metric_name(name: String) -> bool { 15+pub fn validate_metric_name(name: &str) -> bool {
AlertManager::evaluate_and_persist() tiene 78 líneas mezclando validación,
lógica de negocio, llamada a BD y notificación. Límite del equipo: 50 líneas.
validate_threshold(), persist_alert() y notify_channel()
como funciones independientes con responsabilidad única.
Se crea un Vec<Alert> por cada batch de métricas sin reservar capacidad,
provocando múltiples reallocaciones en producción con batches de 1 000+ métricas.
61- let mut alerts = Vec::new(); 61+ let mut alerts = Vec::with_capacity(metrics.len());
cargo audit detecta un use-after-free en la versión transitiva de OpenSSL.
Aunque es una dependencia indirecta, el servicio sirve TLS.
reqwest a 0.12+ (usa rustls por defecto) o fija
openssl = "0.10.66" en [dependencies] con override hasta que reqwest actualice.
| Severidad | Categoría | Descripción | Archivo | Estado |
|---|---|---|---|---|
| Crítico | Seguridad | Inyección SQL por interpolación de strings | src/db.rs:67 |
🚫 BLOQUEA |
| Crítico | Seguridad | unsafe sin comentario SAFETY | src/alerts.rs:112 |
🚫 BLOQUEA |
| Crítico | Error Handling | unwrap() en rutas de producción (x3) | src/db.rs:71, src/ingestion.rs:38 |
🚫 BLOQUEA |
| Alto | Concurrencia | Canal mpsc sin bounds | src/ingestion.rs:22 |
⚠ BLOQUEA |
| Alto | Concurrencia | std::thread::sleep en contexto async | src/alerts.rs:89 |
⚠ BLOQUEA |
| Alto | Ownership | String en lugar de &str en API pública | src/alerts.rs:15,42 |
⚠ BLOQUEA |
| Alto | Calidad | Función de 78 líneas (límite: 50) | src/alerts.rs:55 |
⚠ BLOQUEA |
| Medio | Rendimiento | Vec::new() sin with_capacity en bucle | src/ingestion.rs:61 |
— advertencia |
| Medio | Dependencias | openssl vulnerable (RUSTSEC-2024-0421) | Cargo.lock | — advertencia |
| Medio | Calidad | Importación no usada (HashMap) rompe clippy -D warnings | src/alerts.rs:3 |
— advertencia |
| Medio | Formato | cargo fmt --check falla en db.rs:67 | src/db.rs:67 |
— advertencia |
| Medio | Docs | AlertManager pub sin documentación /// | src/alerts.rs:10 |
— advertencia |