""" Alerts API blueprint. Handles endpoints for viewing alert history and managing alert rules. """ import json from datetime import datetime, timedelta, timezone from flask import Blueprint, jsonify, request, current_app from web.auth.decorators import api_auth_required from web.models import Alert, AlertRule, Scan from web.services.alert_service import AlertService bp = Blueprint('alerts', __name__) @bp.route('', methods=['GET']) @api_auth_required def list_alerts(): """ List recent alerts. Query params: page: Page number (default: 1) per_page: Items per page (default: 20) alert_type: Filter by alert type severity: Filter by severity (info, warning, critical) acknowledged: Filter by acknowledgment status (true/false) scan_id: Filter by specific scan start_date: Filter alerts after this date (ISO format) end_date: Filter alerts before this date (ISO format) Returns: JSON response with alerts list """ # Get query parameters page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 items alert_type = request.args.get('alert_type') severity = request.args.get('severity') acknowledged = request.args.get('acknowledged') scan_id = request.args.get('scan_id', type=int) start_date = request.args.get('start_date') end_date = request.args.get('end_date') # Build query query = current_app.db_session.query(Alert) # Apply filters if alert_type: query = query.filter(Alert.alert_type == alert_type) if severity: query = query.filter(Alert.severity == severity) if acknowledged is not None: ack_bool = acknowledged.lower() == 'true' query = query.filter(Alert.acknowledged == ack_bool) if scan_id: query = query.filter(Alert.scan_id == scan_id) if start_date: try: start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) query = query.filter(Alert.created_at >= start_dt) except ValueError: pass # Ignore invalid date format if end_date: try: end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) query = query.filter(Alert.created_at <= end_dt) except ValueError: pass # Ignore invalid date format # Order by severity and date query = query.order_by( Alert.severity.desc(), # Critical first, then warning, then info Alert.created_at.desc() # Most recent first ) # Paginate total = query.count() alerts = query.offset((page - 1) * per_page).limit(per_page).all() # Format response alerts_data = [] for alert in alerts: # Get scan info scan = current_app.db_session.query(Scan).filter(Scan.id == alert.scan_id).first() alerts_data.append({ 'id': alert.id, 'scan_id': alert.scan_id, 'scan_title': scan.title if scan else None, 'rule_id': alert.rule_id, 'alert_type': alert.alert_type, 'severity': alert.severity, 'message': alert.message, 'ip_address': alert.ip_address, 'port': alert.port, 'acknowledged': alert.acknowledged, 'acknowledged_at': alert.acknowledged_at.isoformat() if alert.acknowledged_at else None, 'acknowledged_by': alert.acknowledged_by, 'email_sent': alert.email_sent, 'email_sent_at': alert.email_sent_at.isoformat() if alert.email_sent_at else None, 'webhook_sent': alert.webhook_sent, 'webhook_sent_at': alert.webhook_sent_at.isoformat() if alert.webhook_sent_at else None, 'created_at': alert.created_at.isoformat() }) return jsonify({ 'alerts': alerts_data, 'total': total, 'page': page, 'per_page': per_page, 'pages': (total + per_page - 1) // per_page # Ceiling division }) @bp.route('//acknowledge', methods=['POST']) @api_auth_required def acknowledge_alert(alert_id): """ Acknowledge an alert. Args: alert_id: Alert ID to acknowledge Returns: JSON response with acknowledgment status """ # Get username from auth context or default to 'api' acknowledged_by = request.json.get('acknowledged_by', 'api') if request.json else 'api' alert_service = AlertService(current_app.db_session) success = alert_service.acknowledge_alert(alert_id, acknowledged_by) if success: return jsonify({ 'status': 'success', 'message': f'Alert {alert_id} acknowledged', 'acknowledged_by': acknowledged_by }) else: return jsonify({ 'status': 'error', 'message': f'Failed to acknowledge alert {alert_id}' }), 400 @bp.route('/rules', methods=['GET']) @api_auth_required def list_alert_rules(): """ List all alert rules. Returns: JSON response with alert rules """ rules = current_app.db_session.query(AlertRule).order_by(AlertRule.name, AlertRule.rule_type).all() rules_data = [] for rule in rules: rules_data.append({ 'id': rule.id, 'name': rule.name, 'rule_type': rule.rule_type, 'enabled': rule.enabled, 'threshold': rule.threshold, 'email_enabled': rule.email_enabled, 'webhook_enabled': rule.webhook_enabled, 'severity': rule.severity, 'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None, 'config_file': rule.config_file, 'created_at': rule.created_at.isoformat(), 'updated_at': rule.updated_at.isoformat() if rule.updated_at else None }) return jsonify({ 'rules': rules_data, 'total': len(rules_data) }) @bp.route('/rules', methods=['POST']) @api_auth_required def create_alert_rule(): """ Create a new alert rule. Request body: name: User-friendly rule name rule_type: Type of alert rule (unexpected_port, drift_detection, cert_expiry, weak_tls, ping_failed) threshold: Threshold value (e.g., days for cert expiry, percentage for drift) enabled: Whether rule is active (default: true) email_enabled: Send email for this rule (default: false) webhook_enabled: Send webhook for this rule (default: false) severity: Alert severity (critical, warning, info) filter_conditions: JSON object with filter conditions config_file: Optional config file to apply rule to Returns: JSON response with created rule """ data = request.get_json() or {} # Validate required fields if not data.get('rule_type'): return jsonify({ 'status': 'error', 'message': 'rule_type is required' }), 400 # Valid rule types valid_rule_types = ['unexpected_port', 'drift_detection', 'cert_expiry', 'weak_tls', 'ping_failed'] if data['rule_type'] not in valid_rule_types: return jsonify({ 'status': 'error', 'message': f'Invalid rule_type. Must be one of: {", ".join(valid_rule_types)}' }), 400 # Valid severities valid_severities = ['critical', 'warning', 'info'] if data.get('severity') and data['severity'] not in valid_severities: return jsonify({ 'status': 'error', 'message': f'Invalid severity. Must be one of: {", ".join(valid_severities)}' }), 400 try: # Create new rule rule = AlertRule( name=data.get('name', f"{data['rule_type']} rule"), rule_type=data['rule_type'], enabled=data.get('enabled', True), threshold=data.get('threshold'), email_enabled=data.get('email_enabled', False), webhook_enabled=data.get('webhook_enabled', False), severity=data.get('severity', 'warning'), filter_conditions=json.dumps(data['filter_conditions']) if data.get('filter_conditions') else None, config_file=data.get('config_file'), created_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc) ) current_app.db_session.add(rule) current_app.db_session.commit() return jsonify({ 'status': 'success', 'message': 'Alert rule created successfully', 'rule': { 'id': rule.id, 'name': rule.name, 'rule_type': rule.rule_type, 'enabled': rule.enabled, 'threshold': rule.threshold, 'email_enabled': rule.email_enabled, 'webhook_enabled': rule.webhook_enabled, 'severity': rule.severity, 'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None, 'config_file': rule.config_file, 'created_at': rule.created_at.isoformat(), 'updated_at': rule.updated_at.isoformat() } }), 201 except Exception as e: current_app.db_session.rollback() return jsonify({ 'status': 'error', 'message': f'Failed to create alert rule: {str(e)}' }), 500 @bp.route('/rules/', methods=['PUT']) @api_auth_required def update_alert_rule(rule_id): """ Update an existing alert rule. Args: rule_id: Alert rule ID to update Request body: name: User-friendly rule name (optional) threshold: Threshold value (optional) enabled: Whether rule is active (optional) email_enabled: Send email for this rule (optional) webhook_enabled: Send webhook for this rule (optional) severity: Alert severity (optional) filter_conditions: JSON object with filter conditions (optional) config_file: Config file to apply rule to (optional) Returns: JSON response with update status """ data = request.get_json() or {} # Get existing rule rule = current_app.db_session.query(AlertRule).filter(AlertRule.id == rule_id).first() if not rule: return jsonify({ 'status': 'error', 'message': f'Alert rule {rule_id} not found' }), 404 # Valid severities valid_severities = ['critical', 'warning', 'info'] if data.get('severity') and data['severity'] not in valid_severities: return jsonify({ 'status': 'error', 'message': f'Invalid severity. Must be one of: {", ".join(valid_severities)}' }), 400 try: # Update fields if provided if 'name' in data: rule.name = data['name'] if 'threshold' in data: rule.threshold = data['threshold'] if 'enabled' in data: rule.enabled = data['enabled'] if 'email_enabled' in data: rule.email_enabled = data['email_enabled'] if 'webhook_enabled' in data: rule.webhook_enabled = data['webhook_enabled'] if 'severity' in data: rule.severity = data['severity'] if 'filter_conditions' in data: rule.filter_conditions = json.dumps(data['filter_conditions']) if data['filter_conditions'] else None if 'config_file' in data: rule.config_file = data['config_file'] rule.updated_at = datetime.now(timezone.utc) current_app.db_session.commit() return jsonify({ 'status': 'success', 'message': 'Alert rule updated successfully', 'rule': { 'id': rule.id, 'name': rule.name, 'rule_type': rule.rule_type, 'enabled': rule.enabled, 'threshold': rule.threshold, 'email_enabled': rule.email_enabled, 'webhook_enabled': rule.webhook_enabled, 'severity': rule.severity, 'filter_conditions': json.loads(rule.filter_conditions) if rule.filter_conditions else None, 'config_file': rule.config_file, 'created_at': rule.created_at.isoformat(), 'updated_at': rule.updated_at.isoformat() } }) except Exception as e: current_app.db_session.rollback() return jsonify({ 'status': 'error', 'message': f'Failed to update alert rule: {str(e)}' }), 500 @bp.route('/rules/', methods=['DELETE']) @api_auth_required def delete_alert_rule(rule_id): """ Delete an alert rule. Args: rule_id: Alert rule ID to delete Returns: JSON response with deletion status """ # Get existing rule rule = current_app.db_session.query(AlertRule).filter(AlertRule.id == rule_id).first() if not rule: return jsonify({ 'status': 'error', 'message': f'Alert rule {rule_id} not found' }), 404 try: # Delete the rule (cascade will delete related alerts) current_app.db_session.delete(rule) current_app.db_session.commit() return jsonify({ 'status': 'success', 'message': f'Alert rule {rule_id} deleted successfully' }) except Exception as e: current_app.db_session.rollback() return jsonify({ 'status': 'error', 'message': f'Failed to delete alert rule: {str(e)}' }), 500 @bp.route('/stats', methods=['GET']) @api_auth_required def alert_stats(): """ Get alert statistics. Query params: days: Number of days to look back (default: 7) Returns: JSON response with alert statistics """ days = request.args.get('days', 7, type=int) cutoff_date = datetime.now(timezone.utc) - timedelta(days=days) # Get alerts in date range alerts = current_app.db_session.query(Alert).filter(Alert.created_at >= cutoff_date).all() # Calculate statistics total_alerts = len(alerts) alerts_by_severity = {'critical': 0, 'warning': 0, 'info': 0} alerts_by_type = {} unacknowledged_count = 0 for alert in alerts: # Count by severity if alert.severity in alerts_by_severity: alerts_by_severity[alert.severity] += 1 # Count by type if alert.alert_type not in alerts_by_type: alerts_by_type[alert.alert_type] = 0 alerts_by_type[alert.alert_type] += 1 # Count unacknowledged if not alert.acknowledged: unacknowledged_count += 1 return jsonify({ 'stats': { 'total_alerts': total_alerts, 'unacknowledged_count': unacknowledged_count, 'alerts_by_severity': alerts_by_severity, 'alerts_by_type': alerts_by_type, 'date_range': { 'start': cutoff_date.isoformat(), 'end': datetime.now(timezone.utc).isoformat(), 'days': days } } }) # Health check endpoint @bp.route('/health', methods=['GET']) def health_check(): """ Health check endpoint for monitoring. Returns: JSON response with API health status """ return jsonify({ 'status': 'healthy', 'api': 'alerts', 'version': '1.0.0-phase5' })