generalbots/src/security/protection/api.rs
Rodrigo Rodriguez (Pragmatismo) faeae250bc Add security protection module with sudo-based privilege escalation
- Create installer.rs for 'botserver install protection' command
- Requires root to install packages and create sudoers config
- Sudoers uses exact commands (no wildcards) for security
- Update all tool files (lynis, rkhunter, chkrootkit, suricata, lmd) to use sudo
- Update manager.rs service management to use sudo
- Add 'sudo' and 'visudo' to command_guard.rs whitelist
- Update CLI with install/remove/status protection commands

Security model:
- Installation requires root (sudo botserver install protection)
- Runtime uses sudoers NOPASSWD for specific commands only
- No wildcards in sudoers - exact command specifications
- Tools run on host system, not in containers
2026-01-10 09:41:12 -03:00

403 lines
13 KiB
Rust

use axum::{
extract::Path,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::warn;
use super::manager::{ProtectionConfig, ProtectionManager, ProtectionTool, ScanResult, ToolStatus};
static PROTECTION_MANAGER: OnceLock<Arc<RwLock<ProtectionManager>>> = OnceLock::new();
fn get_manager() -> &'static Arc<RwLock<ProtectionManager>> {
PROTECTION_MANAGER.get_or_init(|| {
Arc::new(RwLock::new(ProtectionManager::new(ProtectionConfig::default())))
})
}
#[derive(Debug, Serialize)]
struct ApiResponse<T> {
success: bool,
data: Option<T>,
error: Option<String>,
}
impl<T: Serialize> ApiResponse<T> {
fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
}
}
}
impl ApiResponse<()> {
fn error(message: impl Into<String>) -> Self {
Self {
success: false,
data: None,
error: Some(message.into()),
}
}
}
#[derive(Debug, Serialize)]
struct AllStatusResponse {
tools: Vec<ToolStatus>,
}
#[derive(Debug, Deserialize)]
struct AutoToggleRequest {
enabled: bool,
setting: Option<String>,
}
#[derive(Debug, Serialize)]
struct ActionResponse {
success: bool,
message: String,
}
pub fn configure_protection_routes() -> Router {
Router::new()
.route("/api/v1/security/protection/status", get(get_all_status))
.route(
"/api/v1/security/protection/:tool/status",
get(get_tool_status),
)
.route(
"/api/v1/security/protection/:tool/install",
post(install_tool),
)
.route(
"/api/v1/security/protection/:tool/uninstall",
post(uninstall_tool),
)
.route("/api/v1/security/protection/:tool/start", post(start_service))
.route("/api/v1/security/protection/:tool/stop", post(stop_service))
.route(
"/api/v1/security/protection/:tool/enable",
post(enable_service),
)
.route(
"/api/v1/security/protection/:tool/disable",
post(disable_service),
)
.route("/api/v1/security/protection/:tool/run", post(run_scan))
.route("/api/v1/security/protection/:tool/report", get(get_report))
.route(
"/api/v1/security/protection/:tool/update",
post(update_definitions),
)
.route("/api/v1/security/protection/:tool/auto", post(toggle_auto))
.route(
"/api/v1/security/protection/clamav/quarantine",
get(get_quarantine),
)
.route(
"/api/v1/security/protection/clamav/quarantine/:id",
post(remove_from_quarantine),
)
}
fn parse_tool(tool_name: &str) -> Result<ProtectionTool, (StatusCode, Json<ApiResponse<()>>)> {
ProtectionTool::from_str(tool_name).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(ApiResponse::error(format!("Unknown tool: {tool_name}"))),
)
})
}
async fn get_all_status() -> Result<Json<ApiResponse<AllStatusResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let manager = get_manager().read().await;
let status_map = manager.get_all_status().await;
let tools: Vec<ToolStatus> = status_map.into_values().collect();
Ok(Json(ApiResponse::success(AllStatusResponse { tools })))
}
async fn get_tool_status(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ToolStatus>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.check_tool_status(tool).await {
Ok(status) => Ok(Json(ApiResponse::success(status))),
Err(e) => {
warn!(error = %e, "Failed to get tool status");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to get tool status"))))
}
}
}
async fn install_tool(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.install_tool(tool).await {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("{tool} installed successfully"),
}))),
Err(e) => {
warn!(error = %e, "Failed to install tool");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to install tool"))))
}
}
}
async fn uninstall_tool(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
Err((
StatusCode::NOT_IMPLEMENTED,
Json(ApiResponse::error(format!(
"Uninstall not yet implemented for {tool}"
))),
))
}
async fn start_service(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.start_service(tool).await {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("{tool} service started"),
}))),
Err(e) => {
warn!(error = %e, "Failed to start service");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to start service"))))
}
}
}
async fn stop_service(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.stop_service(tool).await {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("{tool} service stopped"),
}))),
Err(e) => {
warn!(error = %e, "Failed to stop service");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to stop service"))))
}
}
}
async fn enable_service(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.enable_service(tool).await {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("{tool} service enabled"),
}))),
Err(e) => {
warn!(error = %e, "Failed to enable service");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to enable service"))))
}
}
}
async fn disable_service(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.disable_service(tool).await {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("{tool} service disabled"),
}))),
Err(e) => {
warn!(error = %e, "Failed to disable service");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to disable service"))))
}
}
}
async fn run_scan(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ScanResult>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.run_scan(tool).await {
Ok(result) => Ok(Json(ApiResponse::success(result))),
Err(e) => {
warn!(error = %e, "Failed to run scan");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to run scan"))))
}
}
}
async fn get_report(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<String>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.get_report(tool).await {
Ok(report) => Ok(Json(ApiResponse::success(report))),
Err(e) => {
warn!(error = %e, "Failed to get report");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to get report"))))
}
}
}
async fn update_definitions(
Path(tool_name): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().read().await;
match manager.update_definitions(tool).await {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("{tool} definitions updated"),
}))),
Err(e) => {
warn!(error = %e, "Failed to update definitions");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to update definitions"))))
}
}
}
async fn toggle_auto(
Path(tool_name): Path<String>,
Json(request): Json<AutoToggleRequest>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
let tool = parse_tool(&tool_name)?;
let manager = get_manager().write().await;
let setting = request.setting.as_deref().unwrap_or("update");
let result = match setting {
"update" => manager.set_auto_update(tool, request.enabled).await,
"remediate" => manager.set_auto_remediate(tool, request.enabled).await,
_ => manager.set_auto_update(tool, request.enabled).await,
};
match result {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("{tool} {setting} set to {}", request.enabled),
}))),
Err(e) => {
warn!(error = %e, "Failed to toggle auto setting");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to toggle auto setting"))))
}
}
}
async fn get_quarantine() -> Result<Json<ApiResponse<Vec<super::lmd::QuarantinedFile>>>, (StatusCode, Json<ApiResponse<()>>)> {
match super::lmd::list_quarantined().await {
Ok(files) => Ok(Json(ApiResponse::success(files))),
Err(e) => {
warn!(error = %e, "Failed to get quarantine list");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to get quarantine list"))))
}
}
}
async fn remove_from_quarantine(
Path(file_id): Path<String>,
) -> Result<Json<ApiResponse<ActionResponse>>, (StatusCode, Json<ApiResponse<()>>)> {
match super::lmd::restore_file(&file_id).await {
Ok(()) => Ok(Json(ApiResponse::success(ActionResponse {
success: true,
message: format!("File {file_id} restored from quarantine"),
}))),
Err(e) => {
warn!(error = %e, "Failed to restore file from quarantine");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::error("Failed to restore file from quarantine"))))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tool_valid() {
assert!(parse_tool("lynis").is_ok());
assert!(parse_tool("rkhunter").is_ok());
assert!(parse_tool("clamav").is_ok());
assert!(parse_tool("LYNIS").is_ok());
}
#[test]
fn test_parse_tool_invalid() {
assert!(parse_tool("unknown").is_err());
assert!(parse_tool("").is_err());
}
#[test]
fn test_api_response_success() {
let response = ApiResponse::success("test data");
assert!(response.success);
assert!(response.data.is_some());
assert!(response.error.is_none());
}
#[test]
fn test_api_response_error() {
let response: ApiResponse<()> = ApiResponse::error("test error".to_string());
assert!(!response.success);
assert!(response.data.is_none());
assert!(response.error.is_some());
}
#[test]
fn test_action_response() {
let response = ActionResponse {
success: true,
message: "Test message".to_string(),
};
assert!(response.success);
assert_eq!(response.message, "Test message");
}
#[test]
fn test_auto_toggle_request_deserialize() {
let json = r#"{"enabled": true, "setting": "update"}"#;
let request: AutoToggleRequest = serde_json::from_str(json).unwrap();
assert!(request.enabled);
assert_eq!(request.setting, Some("update".to_string()));
}
#[test]
fn test_auto_toggle_request_minimal() {
let json = r#"{"enabled": false}"#;
let request: AutoToggleRequest = serde_json::from_str(json).unwrap();
assert!(!request.enabled);
assert!(request.setting.is_none());
}
}