adding phase 5 init framework, added deployment ease scripts

This commit is contained in:
2025-11-18 13:10:53 -06:00
parent b2a3fc7832
commit 131e1f5a61
19 changed files with 2458 additions and 82 deletions

View File

@@ -4,9 +4,13 @@ Alerts API blueprint.
Handles endpoints for viewing alert history and managing alert rules.
"""
from flask import Blueprint, jsonify, request
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__)
@@ -22,22 +26,126 @@ def list_alerts():
per_page: Items per page (default: 20)
alert_type: Filter by alert type
severity: Filter by severity (info, warning, critical)
start_date: Filter alerts after this date
end_date: Filter alerts before this date
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
"""
# TODO: Implement in Phase 4
# 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': [],
'total': 0,
'page': 1,
'per_page': 20,
'message': 'Alerts list endpoint - to be implemented in Phase 4'
'alerts': alerts_data,
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page # Ceiling division
})
@bp.route('/<int:alert_id>/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():
@@ -47,10 +155,28 @@ def list_alert_rules():
Returns:
JSON response with alert rules
"""
# TODO: Implement in Phase 4
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': [],
'message': 'Alert rules list endpoint - to be implemented in Phase 4'
'rules': rules_data,
'total': len(rules_data)
})
@@ -61,23 +187,88 @@ def create_alert_rule():
Create a new alert rule.
Request body:
rule_type: Type of alert rule
threshold: Threshold value (e.g., days for cert expiry)
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 ID
JSON response with created rule
"""
# TODO: Implement in Phase 4
data = request.get_json() or {}
return jsonify({
'rule_id': None,
'status': 'not_implemented',
'message': 'Alert rule creation endpoint - to be implemented in Phase 4',
'data': data
}), 501
# 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/<int:rule_id>', methods=['PUT'])
@@ -90,22 +281,84 @@ def update_alert_rule(rule_id):
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
"""
# TODO: Implement in Phase 4
data = request.get_json() or {}
return jsonify({
'rule_id': rule_id,
'status': 'not_implemented',
'message': 'Alert rule update endpoint - to be implemented in Phase 4',
'data': data
}), 501
# 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/<int:rule_id>', methods=['DELETE'])
@@ -120,12 +373,83 @@ def delete_alert_rule(rule_id):
Returns:
JSON response with deletion status
"""
# TODO: Implement in Phase 4
# 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({
'rule_id': rule_id,
'status': 'not_implemented',
'message': 'Alert rule deletion endpoint - to be implemented in Phase 4'
}), 501
'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
@@ -140,5 +464,5 @@ def health_check():
return jsonify({
'status': 'healthy',
'api': 'alerts',
'version': '1.0.0-phase1'
})
'version': '1.0.0-phase5'
})

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import sessionmaker
from src.scanner import SneakyScanner
from web.models import Scan
from web.services.scan_service import ScanService
from web.services.alert_service import AlertService
logger = logging.getLogger(__name__)
@@ -89,6 +90,17 @@ def execute_scan(scan_id: int, config_file: str, db_url: str):
scan_service = ScanService(session)
scan_service._save_scan_to_db(report, scan_id, status='completed')
# Evaluate alert rules
logger.info(f"Scan {scan_id}: Evaluating alert rules...")
try:
alert_service = AlertService(session)
alerts_triggered = alert_service.evaluate_alert_rules(scan_id)
logger.info(f"Scan {scan_id}: {len(alerts_triggered)} alerts triggered")
except Exception as e:
# Don't fail the scan if alert evaluation fails
logger.error(f"Scan {scan_id}: Alert evaluation failed: {str(e)}")
logger.debug(f"Alert evaluation error details: {traceback.format_exc()}")
logger.info(f"Scan {scan_id}: Completed successfully")
except FileNotFoundError as e:

View File

@@ -284,17 +284,24 @@ class Alert(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
alert_type = Column(String(50), nullable=False, comment="new_port, cert_expiry, service_change, ping_failed")
rule_id = Column(Integer, ForeignKey('alert_rules.id'), nullable=True, index=True, comment="Associated alert rule")
alert_type = Column(String(50), nullable=False, comment="unexpected_port, drift_detection, cert_expiry, service_change, ping_failed")
severity = Column(String(20), nullable=False, comment="info, warning, critical")
message = Column(Text, nullable=False, comment="Human-readable alert message")
ip_address = Column(String(45), nullable=True, comment="Related IP (optional)")
port = Column(Integer, nullable=True, comment="Related port (optional)")
email_sent = Column(Boolean, nullable=False, default=False, comment="Was email notification sent?")
email_sent_at = Column(DateTime, nullable=True, comment="Email send timestamp")
webhook_sent = Column(Boolean, nullable=False, default=False, comment="Was webhook sent?")
webhook_sent_at = Column(DateTime, nullable=True, comment="Webhook send timestamp")
acknowledged = Column(Boolean, nullable=False, default=False, index=True, comment="Was alert acknowledged?")
acknowledged_at = Column(DateTime, nullable=True, comment="Acknowledgment timestamp")
acknowledged_by = Column(String(255), nullable=True, comment="User who acknowledged")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Alert creation time")
# Relationships
scan = relationship('Scan', back_populates='alerts')
rule = relationship('AlertRule', back_populates='alerts')
# Index for alert queries by type and severity
__table_args__ = (
@@ -315,14 +322,79 @@ class AlertRule(Base):
__tablename__ = 'alert_rules'
id = Column(Integer, primary_key=True, autoincrement=True)
rule_type = Column(String(50), nullable=False, comment="unexpected_port, cert_expiry, service_down, etc.")
name = Column(String(255), nullable=True, comment="User-friendly rule name")
rule_type = Column(String(50), nullable=False, comment="unexpected_port, cert_expiry, service_down, drift_detection, etc.")
enabled = Column(Boolean, nullable=False, default=True, comment="Is rule active?")
threshold = Column(Integer, nullable=True, comment="Threshold value (e.g., days for cert expiry)")
email_enabled = Column(Boolean, nullable=False, default=False, comment="Send email for this rule?")
webhook_enabled = Column(Boolean, nullable=False, default=False, comment="Send webhook for this rule?")
severity = Column(String(20), nullable=True, comment="Alert severity: critical, warning, info")
filter_conditions = Column(Text, nullable=True, comment="JSON filter conditions for the rule")
config_file = Column(String(255), nullable=True, comment="Optional: specific config file this rule applies to")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Rule creation time")
updated_at = Column(DateTime, nullable=True, comment="Last update time")
# Relationships
alerts = relationship("Alert", back_populates="rule", cascade="all, delete-orphan")
def __repr__(self):
return f"<AlertRule(id={self.id}, rule_type='{self.rule_type}', enabled={self.enabled})>"
return f"<AlertRule(id={self.id}, name='{self.name}', rule_type='{self.rule_type}', enabled={self.enabled})>"
class Webhook(Base):
"""
Webhook configurations for alert notifications.
Stores webhook endpoints and authentication details for sending alert
notifications to external systems.
"""
__tablename__ = 'webhooks'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, comment="Webhook name")
url = Column(Text, nullable=False, comment="Webhook URL")
enabled = Column(Boolean, nullable=False, default=True, comment="Is webhook enabled?")
auth_type = Column(String(20), nullable=True, comment="Authentication type: none, bearer, basic, custom")
auth_token = Column(Text, nullable=True, comment="Encrypted authentication token")
custom_headers = Column(Text, nullable=True, comment="JSON custom headers")
alert_types = Column(Text, nullable=True, comment="JSON array of alert types to trigger on")
severity_filter = Column(Text, nullable=True, comment="JSON array of severities to trigger on")
timeout = Column(Integer, nullable=True, default=10, comment="Request timeout in seconds")
retry_count = Column(Integer, nullable=True, default=3, comment="Number of retry attempts")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Creation time")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Last update time")
# Relationships
delivery_logs = relationship("WebhookDeliveryLog", back_populates="webhook", cascade="all, delete-orphan")
def __repr__(self):
return f"<Webhook(id={self.id}, name='{self.name}', enabled={self.enabled})>"
class WebhookDeliveryLog(Base):
"""
Webhook delivery tracking.
Logs all webhook delivery attempts for auditing and debugging purposes.
"""
__tablename__ = 'webhook_delivery_log'
id = Column(Integer, primary_key=True, autoincrement=True)
webhook_id = Column(Integer, ForeignKey('webhooks.id'), nullable=False, index=True, comment="Associated webhook")
alert_id = Column(Integer, ForeignKey('alerts.id'), nullable=False, index=True, comment="Associated alert")
status = Column(String(20), nullable=True, index=True, comment="Delivery status: success, failed, retrying")
response_code = Column(Integer, nullable=True, comment="HTTP response code")
response_body = Column(Text, nullable=True, comment="Response body from webhook")
error_message = Column(Text, nullable=True, comment="Error message if failed")
attempt_number = Column(Integer, nullable=True, comment="Which attempt this was")
delivered_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Delivery timestamp")
# Relationships
webhook = relationship("Webhook", back_populates="delivery_logs")
alert = relationship("Alert")
def __repr__(self):
return f"<WebhookDeliveryLog(id={self.id}, webhook_id={self.webhook_id}, status='{self.status}')>"
# ============================================================================

View File

@@ -219,3 +219,105 @@ def edit_config(filename):
logger.error(f"Error loading config for edit: {e}")
flash(f"Error loading config: {str(e)}", 'error')
return redirect(url_for('main.configs'))
@bp.route('/alerts')
@login_required
def alerts():
"""
Alerts history page - shows all alerts.
Returns:
Rendered alerts template
"""
from flask import request, current_app
from web.models import Alert, AlertRule, Scan
from web.utils.pagination import paginate
# Get query parameters for filtering
page = request.args.get('page', 1, type=int)
per_page = 20
severity = request.args.get('severity')
alert_type = request.args.get('alert_type')
acknowledged = request.args.get('acknowledged')
# Build query
query = current_app.db_session.query(Alert).join(Scan, isouter=True)
# Apply filters
if severity:
query = query.filter(Alert.severity == severity)
if alert_type:
query = query.filter(Alert.alert_type == alert_type)
if acknowledged is not None:
ack_bool = acknowledged == 'true'
query = query.filter(Alert.acknowledged == ack_bool)
# Order by severity and date
query = query.order_by(Alert.severity.desc(), Alert.created_at.desc())
# Paginate using utility function
pagination = paginate(query, page=page, per_page=per_page)
alerts = pagination.items
# Get unique alert types for filter dropdown
try:
alert_types = current_app.db_session.query(Alert.alert_type).distinct().all()
alert_types = [at[0] for at in alert_types] if alert_types else []
except Exception:
alert_types = []
return render_template(
'alerts.html',
alerts=alerts,
pagination=pagination,
current_severity=severity,
current_alert_type=alert_type,
current_acknowledged=acknowledged,
alert_types=alert_types
)
@bp.route('/alerts/rules')
@login_required
def alert_rules():
"""
Alert rules management page.
Returns:
Rendered alert rules template
"""
import os
from flask import current_app
from web.models import AlertRule
# Get all alert rules with error handling
try:
rules = current_app.db_session.query(AlertRule).order_by(
AlertRule.name.nullslast(),
AlertRule.rule_type
).all()
except Exception as e:
logger.error(f"Error fetching alert rules: {e}")
rules = []
# Ensure rules is always a list
if rules is None:
rules = []
# Get list of available config files
configs_dir = '/app/configs'
config_files = []
try:
if os.path.exists(configs_dir):
config_files = [f for f in os.listdir(configs_dir) if f.endswith(('.yaml', '.yml'))]
config_files.sort()
except Exception as e:
logger.error(f"Error listing config files: {e}")
return render_template(
'alert_rules.html',
rules=rules,
config_files=config_files
)

View File

@@ -0,0 +1,490 @@
"""
Alert Service Module
Handles alert evaluation, rule processing, and notification triggering
for SneakyScan Phase 5.
"""
import logging
from datetime import datetime, timezone
from typing import List, Dict, Optional, Any
from sqlalchemy.orm import Session
from ..models import (
Alert, AlertRule, Scan, ScanPort, ScanIP, ScanService as ScanServiceModel,
ScanCertificate, ScanTLSVersion
)
from .scan_service import ScanService
logger = logging.getLogger(__name__)
class AlertService:
"""
Service for evaluating alert rules and generating alerts based on scan results.
Supports two main alert types:
1. Unexpected Port Detection - Alerts when ports marked as unexpected are found open
2. Drift Detection - Alerts when scan results differ from previous scan
"""
def __init__(self, db_session: Session):
self.db = db_session
self.scan_service = ScanService(db_session)
def evaluate_alert_rules(self, scan_id: int) -> List[Alert]:
"""
Main entry point for alert evaluation after scan completion.
Args:
scan_id: ID of the completed scan to evaluate
Returns:
List of Alert objects that were created
"""
logger.info(f"Starting alert evaluation for scan {scan_id}")
# Get the scan
scan = self.db.query(Scan).filter(Scan.id == scan_id).first()
if not scan:
logger.error(f"Scan {scan_id} not found")
return []
# Get all enabled alert rules
rules = self.db.query(AlertRule).filter(AlertRule.enabled == True).all()
logger.info(f"Found {len(rules)} enabled alert rules to evaluate")
alerts_created = []
for rule in rules:
try:
# Check if rule applies to this scan's config
if rule.config_file and scan.config_file != rule.config_file:
logger.debug(f"Skipping rule {rule.id} - config mismatch")
continue
# Evaluate based on rule type
alert_data = []
if rule.rule_type == 'unexpected_port':
alert_data = self.check_unexpected_ports(scan, rule)
elif rule.rule_type == 'drift_detection':
alert_data = self.check_drift_from_previous(scan, rule)
elif rule.rule_type == 'cert_expiry':
alert_data = self.check_certificate_expiry(scan, rule)
elif rule.rule_type == 'weak_tls':
alert_data = self.check_weak_tls(scan, rule)
elif rule.rule_type == 'ping_failed':
alert_data = self.check_ping_failures(scan, rule)
else:
logger.warning(f"Unknown rule type: {rule.rule_type}")
continue
# Create alerts for any findings
for alert_info in alert_data:
alert = self.create_alert(scan_id, rule, alert_info)
if alert:
alerts_created.append(alert)
# Trigger notifications if configured
if rule.email_enabled or rule.webhook_enabled:
self.trigger_notifications(alert, rule)
logger.info(f"Rule {rule.name or rule.id} generated {len(alert_data)} alerts")
except Exception as e:
logger.error(f"Error evaluating rule {rule.id}: {str(e)}")
continue
logger.info(f"Alert evaluation complete. Created {len(alerts_created)} alerts")
return alerts_created
def check_unexpected_ports(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Detect ports that are open but not in the expected_ports list.
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Get all ports where expected=False
unexpected_ports = (
self.db.query(ScanPort, ScanIP)
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
.filter(ScanPort.scan_id == scan.id)
.filter(ScanPort.expected == False) # Not in config's expected_ports
.filter(ScanPort.state == 'open')
.all()
)
# High-risk ports that should trigger critical alerts
high_risk_ports = {
22, # SSH
23, # Telnet
135, # Windows RPC
139, # NetBIOS
445, # SMB
1433, # SQL Server
3306, # MySQL
3389, # RDP
5432, # PostgreSQL
5900, # VNC
6379, # Redis
9200, # Elasticsearch
27017, # MongoDB
}
for port, ip in unexpected_ports:
# Determine severity based on port number
severity = rule.severity or ('critical' if port.port in high_risk_ports else 'warning')
# Get service info if available
service = (
self.db.query(ScanServiceModel)
.filter(ScanServiceModel.port_id == port.id)
.first()
)
service_info = ""
if service:
product = service.product or "Unknown"
version = service.version or ""
service_info = f" (Service: {service.service_name}: {product} {version}".strip() + ")"
alerts_to_create.append({
'alert_type': 'unexpected_port',
'severity': severity,
'message': f"Unexpected port open on {ip.ip_address}:{port.port}/{port.protocol}{service_info}",
'ip_address': ip.ip_address,
'port': port.port
})
return alerts_to_create
def check_drift_from_previous(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Compare current scan to the last scan with the same config.
Args:
scan: The current scan
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Find previous scan with same config_file
previous_scan = (
self.db.query(Scan)
.filter(Scan.config_file == scan.config_file)
.filter(Scan.id < scan.id)
.filter(Scan.status == 'completed')
.order_by(Scan.started_at.desc() if Scan.started_at else Scan.timestamp.desc())
.first()
)
if not previous_scan:
logger.info(f"No previous scan found for config {scan.config_file}")
return []
try:
# Use existing comparison logic from scan_service
comparison = self.scan_service.compare_scans(previous_scan.id, scan.id)
# Alert on new ports
for port_data in comparison.get('ports', {}).get('added', []):
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'drift_new_port',
'severity': severity,
'message': f"New port detected: {port_data['ip']}:{port_data['port']}/{port_data['protocol']}",
'ip_address': port_data['ip'],
'port': port_data['port']
})
# Alert on removed ports
for port_data in comparison.get('ports', {}).get('removed', []):
severity = rule.severity or 'info'
alerts_to_create.append({
'alert_type': 'drift_missing_port',
'severity': severity,
'message': f"Port no longer open: {port_data['ip']}:{port_data['port']}/{port_data['protocol']}",
'ip_address': port_data['ip'],
'port': port_data['port']
})
# Alert on service changes
for svc_data in comparison.get('services', {}).get('changed', []):
old_svc = svc_data.get('old', {})
new_svc = svc_data.get('new', {})
old_desc = f"{old_svc.get('product', 'Unknown')} {old_svc.get('version', '')}".strip()
new_desc = f"{new_svc.get('product', 'Unknown')} {new_svc.get('version', '')}".strip()
severity = rule.severity or 'info'
alerts_to_create.append({
'alert_type': 'drift_service_change',
'severity': severity,
'message': f"Service changed on {svc_data['ip']}:{svc_data['port']}: {old_desc}{new_desc}",
'ip_address': svc_data['ip'],
'port': svc_data['port']
})
# Alert on certificate changes
for cert_data in comparison.get('certificates', {}).get('changed', []):
old_cert = cert_data.get('old', {})
new_cert = cert_data.get('new', {})
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'drift_cert_change',
'severity': severity,
'message': f"Certificate changed on {cert_data['ip']}:{cert_data['port']} - "
f"Subject: {old_cert.get('subject', 'Unknown')}{new_cert.get('subject', 'Unknown')}",
'ip_address': cert_data['ip'],
'port': cert_data['port']
})
# Check drift score threshold if configured
if rule.threshold and comparison.get('drift_score', 0) * 100 >= rule.threshold:
alerts_to_create.append({
'alert_type': 'drift_threshold_exceeded',
'severity': rule.severity or 'warning',
'message': f"Drift score {comparison['drift_score']*100:.1f}% exceeds threshold {rule.threshold}%",
'ip_address': None,
'port': None
})
except Exception as e:
logger.error(f"Error comparing scans: {str(e)}")
return alerts_to_create
def check_certificate_expiry(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Check for certificates expiring within the threshold days.
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
threshold_days = rule.threshold or 30 # Default 30 days
# Get all certificates from the scan
certificates = (
self.db.query(ScanCertificate, ScanPort, ScanIP)
.join(ScanPort, ScanCertificate.port_id == ScanPort.id)
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
.filter(ScanPort.scan_id == scan.id)
.all()
)
for cert, port, ip in certificates:
if cert.days_until_expiry is not None and cert.days_until_expiry <= threshold_days:
if cert.days_until_expiry <= 0:
severity = 'critical'
message = f"Certificate EXPIRED on {ip.ip_address}:{port.port}"
elif cert.days_until_expiry <= 7:
severity = 'critical'
message = f"Certificate expires in {cert.days_until_expiry} days on {ip.ip_address}:{port.port}"
elif cert.days_until_expiry <= 14:
severity = 'warning'
message = f"Certificate expires in {cert.days_until_expiry} days on {ip.ip_address}:{port.port}"
else:
severity = 'info'
message = f"Certificate expires in {cert.days_until_expiry} days on {ip.ip_address}:{port.port}"
alerts_to_create.append({
'alert_type': 'cert_expiry',
'severity': severity,
'message': message,
'ip_address': ip.ip_address,
'port': port.port
})
return alerts_to_create
def check_weak_tls(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Check for weak TLS versions (1.0, 1.1).
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Get all TLS version data from the scan
tls_versions = (
self.db.query(ScanTLSVersion, ScanPort, ScanIP)
.join(ScanPort, ScanTLSVersion.port_id == ScanPort.id)
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
.filter(ScanPort.scan_id == scan.id)
.all()
)
for tls, port, ip in tls_versions:
weak_versions = []
if tls.tls_1_0:
weak_versions.append("TLS 1.0")
if tls.tls_1_1:
weak_versions.append("TLS 1.1")
if weak_versions:
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'weak_tls',
'severity': severity,
'message': f"Weak TLS versions supported on {ip.ip_address}:{port.port}: {', '.join(weak_versions)}",
'ip_address': ip.ip_address,
'port': port.port
})
return alerts_to_create
def check_ping_failures(self, scan: Scan, rule: AlertRule) -> List[Dict[str, Any]]:
"""
Check for hosts that were expected to respond to ping but didn't.
Args:
scan: The scan to check
rule: The alert rule configuration
Returns:
List of alert data dictionaries
"""
alerts_to_create = []
# Get all IPs where ping was expected but failed
failed_pings = (
self.db.query(ScanIP)
.filter(ScanIP.scan_id == scan.id)
.filter(ScanIP.ping_expected == True)
.filter(ScanIP.ping_actual == False)
.all()
)
for ip in failed_pings:
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'ping_failed',
'severity': severity,
'message': f"Host {ip.ip_address} did not respond to ping (expected to be up)",
'ip_address': ip.ip_address,
'port': None
})
return alerts_to_create
def create_alert(self, scan_id: int, rule: AlertRule, alert_data: Dict[str, Any]) -> Optional[Alert]:
"""
Create an alert record in the database.
Args:
scan_id: ID of the scan that triggered the alert
rule: The alert rule that was triggered
alert_data: Dictionary with alert details
Returns:
Created Alert object or None if creation failed
"""
try:
alert = Alert(
scan_id=scan_id,
rule_id=rule.id,
alert_type=alert_data['alert_type'],
severity=alert_data['severity'],
message=alert_data['message'],
ip_address=alert_data.get('ip_address'),
port=alert_data.get('port'),
created_at=datetime.now(timezone.utc)
)
self.db.add(alert)
self.db.commit()
logger.info(f"Created alert: {alert.message}")
return alert
except Exception as e:
logger.error(f"Failed to create alert: {str(e)}")
self.db.rollback()
return None
def trigger_notifications(self, alert: Alert, rule: AlertRule):
"""
Send notifications for an alert based on rule configuration.
Args:
alert: The alert to send notifications for
rule: The rule that specifies notification settings
"""
# Email notification will be implemented in email_service.py
if rule.email_enabled:
logger.info(f"Email notification would be sent for alert {alert.id}")
# TODO: Call email service
# Webhook notification will be implemented in webhook_service.py
if rule.webhook_enabled:
logger.info(f"Webhook notification would be sent for alert {alert.id}")
# TODO: Call webhook service
def acknowledge_alert(self, alert_id: int, acknowledged_by: str = "system") -> bool:
"""
Acknowledge an alert.
Args:
alert_id: ID of the alert to acknowledge
acknowledged_by: Username or system identifier
Returns:
True if successful, False otherwise
"""
try:
alert = self.db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
logger.error(f"Alert {alert_id} not found")
return False
alert.acknowledged = True
alert.acknowledged_at = datetime.now(timezone.utc)
alert.acknowledged_by = acknowledged_by
self.db.commit()
logger.info(f"Alert {alert_id} acknowledged by {acknowledged_by}")
return True
except Exception as e:
logger.error(f"Failed to acknowledge alert {alert_id}: {str(e)}")
self.db.rollback()
return False
def get_alerts_for_scan(self, scan_id: int) -> List[Alert]:
"""
Get all alerts for a specific scan.
Args:
scan_id: ID of the scan
Returns:
List of Alert objects
"""
return (
self.db.query(Alert)
.filter(Alert.scan_id == scan_id)
.order_by(Alert.severity.desc(), Alert.created_at.desc())
.all()
)

View File

@@ -0,0 +1,474 @@
{% extends "base.html" %}
{% block title %}Alert Rules - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Alert Rules</h1>
<div>
<a href="{{ url_for('main.alerts') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-bell"></i> View Alerts
</a>
<button class="btn btn-primary" onclick="showCreateRuleModal()">
<i class="bi bi-plus-circle"></i> Create Rule
</button>
</div>
</div>
</div>
<!-- Rule Statistics -->
<div class="row mb-4">
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Total Rules</h6>
<h3 class="mb-0" style="color: #60a5fa;">{{ rules | length }}</h3>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Active Rules</h6>
<h3 class="mb-0 text-success">{{ rules | selectattr('enabled') | list | length }}</h3>
</div>
</div>
</div>
</div>
<!-- Rules Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Alert Rules Configuration</h5>
</div>
<div class="card-body">
{% if rules %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Severity</th>
<th>Threshold</th>
<th>Config</th>
<th>Notifications</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<td>
<strong>{{ rule.name or 'Unnamed Rule' }}</strong>
<br>
<small class="text-muted">ID: {{ rule.id }}</small>
</td>
<td>
<span class="badge bg-secondary">
{{ rule.rule_type.replace('_', ' ').title() }}
</span>
</td>
<td>
{% if rule.severity == 'critical' %}
<span class="badge bg-danger">Critical</span>
{% elif rule.severity == 'warning' %}
<span class="badge bg-warning">Warning</span>
{% else %}
<span class="badge bg-info">{{ rule.severity or 'Info' }}</span>
{% endif %}
</td>
<td>
{% if rule.threshold %}
{% if rule.rule_type == 'cert_expiry' %}
{{ rule.threshold }} days
{% elif rule.rule_type == 'drift_detection' %}
{{ rule.threshold }}%
{% else %}
{{ rule.threshold }}
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if rule.config_file %}
<small class="text-muted">{{ rule.config_file }}</small>
{% else %}
<span class="badge bg-primary">All Configs</span>
{% endif %}
</td>
<td>
{% if rule.email_enabled %}
<i class="bi bi-envelope-fill text-primary" title="Email enabled"></i>
{% endif %}
{% if rule.webhook_enabled %}
<i class="bi bi-send-fill text-primary" title="Webhook enabled"></i>
{% endif %}
{% if not rule.email_enabled and not rule.webhook_enabled %}
<span class="text-muted">None</span>
{% endif %}
</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox"
id="rule-enabled-{{ rule.id }}"
{% if rule.enabled %}checked{% endif %}
onchange="toggleRule({{ rule.id }}, this.checked)">
<label class="form-check-label" for="rule-enabled-{{ rule.id }}">
{% if rule.enabled %}
<span class="text-success">Active</span>
{% else %}
<span class="text-muted">Inactive</span>
{% endif %}
</label>
</div>
</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="editRule({{ rule.id }})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteRule({{ rule.id }}, '{{ rule.name }}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
<h5 class="mt-3">No alert rules configured</h5>
<p>Create alert rules to be notified of important scan findings.</p>
<button class="btn btn-primary mt-3" onclick="showCreateRuleModal()">
<i class="bi bi-plus-circle"></i> Create Your First Rule
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Create/Edit Rule Modal -->
<div class="modal fade" id="ruleModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ruleModalTitle">Create Alert Rule</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="ruleForm">
<input type="hidden" id="rule-id">
<div class="row">
<div class="col-md-6 mb-3">
<label for="rule-name" class="form-label">Rule Name</label>
<input type="text" class="form-control" id="rule-name" required>
</div>
<div class="col-md-6 mb-3">
<label for="rule-type" class="form-label">Rule Type</label>
<select class="form-select" id="rule-type" required onchange="updateThresholdLabel()">
<option value="">Select a type...</option>
<option value="unexpected_port">Unexpected Port Detection</option>
<option value="drift_detection">Drift Detection</option>
<option value="cert_expiry">Certificate Expiry</option>
<option value="weak_tls">Weak TLS Version</option>
<option value="ping_failed">Ping Failed</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="rule-severity" class="form-label">Severity</label>
<select class="form-select" id="rule-severity" required>
<option value="info">Info</option>
<option value="warning" selected>Warning</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label for="rule-threshold" class="form-label" id="threshold-label">Threshold</label>
<input type="number" class="form-control" id="rule-threshold">
<small class="form-text text-muted" id="threshold-help">
Numeric value that triggers the alert (varies by rule type)
</small>
</div>
</div>
<div class="row">
<div class="col-md-12 mb-3">
<label for="rule-config" class="form-label">Apply to Config (optional)</label>
<select class="form-select" id="rule-config">
<option value="">All Configs (Apply to all scans)</option>
{% if config_files %}
{% for config_file in config_files %}
<option value="{{ config_file }}">{{ config_file }}</option>
{% endfor %}
{% else %}
<option value="" disabled>No config files found</option>
{% endif %}
</select>
<small class="form-text text-muted">
{% if config_files %}
Select a specific config file to limit this rule, or leave as "All Configs" to apply to all scans
{% else %}
No config files found. Upload a config in the Configs section to see available options.
{% endif %}
</small>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rule-email">
<label class="form-check-label" for="rule-email">
Send Email Notifications
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rule-webhook">
<label class="form-check-label" for="rule-webhook">
Send Webhook Notifications
</label>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rule-enabled" checked>
<label class="form-check-label" for="rule-enabled">
Enable this rule immediately
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveRule()">
<span id="save-rule-text">Create Rule</span>
<span id="save-rule-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
<script>
let editingRuleId = null;
function showCreateRuleModal() {
editingRuleId = null;
document.getElementById('ruleModalTitle').textContent = 'Create Alert Rule';
document.getElementById('save-rule-text').textContent = 'Create Rule';
document.getElementById('ruleForm').reset();
document.getElementById('rule-enabled').checked = true;
new bootstrap.Modal(document.getElementById('ruleModal')).show();
}
function editRule(ruleId) {
editingRuleId = ruleId;
document.getElementById('ruleModalTitle').textContent = 'Edit Alert Rule';
document.getElementById('save-rule-text').textContent = 'Update Rule';
// Fetch rule details
fetch(`/api/alerts/rules`, {
headers: {
'X-API-Key': localStorage.getItem('api_key') || ''
}
})
.then(response => response.json())
.then(data => {
const rule = data.rules.find(r => r.id === ruleId);
if (rule) {
document.getElementById('rule-id').value = rule.id;
document.getElementById('rule-name').value = rule.name || '';
document.getElementById('rule-type').value = rule.rule_type;
document.getElementById('rule-severity').value = rule.severity || 'warning';
document.getElementById('rule-threshold').value = rule.threshold || '';
document.getElementById('rule-config').value = rule.config_file || '';
document.getElementById('rule-email').checked = rule.email_enabled;
document.getElementById('rule-webhook').checked = rule.webhook_enabled;
document.getElementById('rule-enabled').checked = rule.enabled;
updateThresholdLabel();
new bootstrap.Modal(document.getElementById('ruleModal')).show();
}
})
.catch(error => {
console.error('Error fetching rule:', error);
alert('Failed to load rule details');
});
}
function updateThresholdLabel() {
const ruleType = document.getElementById('rule-type').value;
const label = document.getElementById('threshold-label');
const help = document.getElementById('threshold-help');
switch(ruleType) {
case 'cert_expiry':
label.textContent = 'Days Before Expiry';
help.textContent = 'Alert when certificate expires within this many days (default: 30)';
break;
case 'drift_detection':
label.textContent = 'Drift Percentage';
help.textContent = 'Alert when drift exceeds this percentage (0-100, default: 5)';
break;
case 'unexpected_port':
label.textContent = 'Threshold (optional)';
help.textContent = 'Leave blank - this rule alerts on any port not in your config file';
break;
case 'weak_tls':
label.textContent = 'Threshold (optional)';
help.textContent = 'Leave blank - this rule alerts on TLS versions below 1.2';
break;
case 'ping_failed':
label.textContent = 'Threshold (optional)';
help.textContent = 'Leave blank - this rule alerts when a host fails to respond to ping';
break;
default:
label.textContent = 'Threshold';
help.textContent = 'Numeric value that triggers the alert (select a rule type for specific guidance)';
}
}
function saveRule() {
const name = document.getElementById('rule-name').value;
const ruleType = document.getElementById('rule-type').value;
const severity = document.getElementById('rule-severity').value;
const threshold = document.getElementById('rule-threshold').value;
const configFile = document.getElementById('rule-config').value;
const emailEnabled = document.getElementById('rule-email').checked;
const webhookEnabled = document.getElementById('rule-webhook').checked;
const enabled = document.getElementById('rule-enabled').checked;
if (!name || !ruleType) {
alert('Please fill in required fields');
return;
}
const data = {
name: name,
rule_type: ruleType,
severity: severity,
threshold: threshold ? parseInt(threshold) : null,
config_file: configFile || null,
email_enabled: emailEnabled,
webhook_enabled: webhookEnabled,
enabled: enabled
};
// Show spinner
document.getElementById('save-rule-text').style.display = 'none';
document.getElementById('save-rule-spinner').style.display = 'inline-block';
const url = editingRuleId
? `/api/alerts/rules/${editingRuleId}`
: '/api/alerts/rules';
const method = editingRuleId ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('api_key') || ''
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Failed to save rule: ' + (data.message || 'Unknown error'));
// Hide spinner
document.getElementById('save-rule-text').style.display = 'inline';
document.getElementById('save-rule-spinner').style.display = 'none';
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to save rule');
// Hide spinner
document.getElementById('save-rule-text').style.display = 'inline';
document.getElementById('save-rule-spinner').style.display = 'none';
});
}
function toggleRule(ruleId, enabled) {
fetch(`/api/alerts/rules/${ruleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('api_key') || ''
},
body: JSON.stringify({ enabled: enabled })
})
.then(response => response.json())
.then(data => {
if (data.status !== 'success') {
alert('Failed to update rule status');
// Revert checkbox
document.getElementById(`rule-enabled-${ruleId}`).checked = !enabled;
} else {
// Update label
const label = document.querySelector(`label[for="rule-enabled-${ruleId}"] span`);
if (enabled) {
label.className = 'text-success';
label.textContent = 'Active';
} else {
label.className = 'text-muted';
label.textContent = 'Inactive';
}
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update rule status');
// Revert checkbox
document.getElementById(`rule-enabled-${ruleId}`).checked = !enabled;
});
}
function deleteRule(ruleId, ruleName) {
if (!confirm(`Delete alert rule "${ruleName}"? This cannot be undone.`)) {
return;
}
fetch(`/api/alerts/rules/${ruleId}`, {
method: 'DELETE',
headers: {
'X-API-Key': localStorage.getItem('api_key') || ''
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Failed to delete rule: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to delete rule');
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,269 @@
{% extends "base.html" %}
{% block title %}Alerts - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Alert History</h1>
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary">
<i class="bi bi-gear"></i> Manage Alert Rules
</a>
</div>
</div>
<!-- Alert Statistics -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Total Alerts</h6>
<h3 class="mb-0" style="color: #60a5fa;">{{ pagination.total }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Critical</h6>
<h3 class="mb-0 text-danger">
{{ alerts | selectattr('severity', 'equalto', 'critical') | list | length }}
</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Warnings</h6>
<h3 class="mb-0 text-warning">
{{ alerts | selectattr('severity', 'equalto', 'warning') | list | length }}
</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Unacknowledged</h6>
<h3 class="mb-0" style="color: #f97316;">
{{ alerts | rejectattr('acknowledged') | list | length }}
</h3>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<form method="get" action="{{ url_for('main.alerts') }}" class="row g-3">
<div class="col-md-3">
<label for="severity-filter" class="form-label">Severity</label>
<select class="form-select" id="severity-filter" name="severity">
<option value="">All Severities</option>
<option value="critical" {% if current_severity == 'critical' %}selected{% endif %}>Critical</option>
<option value="warning" {% if current_severity == 'warning' %}selected{% endif %}>Warning</option>
<option value="info" {% if current_severity == 'info' %}selected{% endif %}>Info</option>
</select>
</div>
<div class="col-md-3">
<label for="type-filter" class="form-label">Alert Type</label>
<select class="form-select" id="type-filter" name="alert_type">
<option value="">All Types</option>
{% for at in alert_types %}
<option value="{{ at }}" {% if current_alert_type == at %}selected{% endif %}>
{{ at.replace('_', ' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="ack-filter" class="form-label">Acknowledgment</label>
<select class="form-select" id="ack-filter" name="acknowledged">
<option value="">All</option>
<option value="false" {% if current_acknowledged == 'false' %}selected{% endif %}>Unacknowledged</option>
<option value="true" {% if current_acknowledged == 'true' %}selected{% endif %}>Acknowledged</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-funnel"></i> Apply Filters
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Alerts Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Alerts</h5>
</div>
<div class="card-body">
{% if alerts %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 100px;">Severity</th>
<th>Type</th>
<th>Message</th>
<th style="width: 120px;">Target</th>
<th style="width: 150px;">Scan</th>
<th style="width: 150px;">Created</th>
<th style="width: 100px;">Status</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
{% for alert in alerts %}
<tr>
<td>
{% if alert.severity == 'critical' %}
<span class="badge bg-danger">Critical</span>
{% elif alert.severity == 'warning' %}
<span class="badge bg-warning">Warning</span>
{% else %}
<span class="badge bg-info">Info</span>
{% endif %}
</td>
<td>
<span class="text-muted">{{ alert.alert_type.replace('_', ' ').title() }}</span>
</td>
<td>
{{ alert.message }}
</td>
<td>
{% if alert.ip_address %}
<small class="text-muted">
{{ alert.ip_address }}{% if alert.port %}:{{ alert.port }}{% endif %}
</small>
{% else %}
<small class="text-muted">-</small>
{% endif %}
</td>
<td>
<a href="{{ url_for('main.scan_detail', scan_id=alert.scan_id) }}" class="text-decoration-none">
Scan #{{ alert.scan_id }}
</a>
</td>
<td>
<small class="text-muted">{{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
</td>
<td>
{% if alert.acknowledged %}
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> Ack'd
</span>
{% else %}
<span class="badge bg-secondary">New</span>
{% endif %}
{% if alert.email_sent %}
<i class="bi bi-envelope-fill text-muted" title="Email sent"></i>
{% endif %}
{% if alert.webhook_sent %}
<i class="bi bi-send-fill text-muted" title="Webhook sent"></i>
{% endif %}
</td>
<td>
{% if not alert.acknowledged %}
<button class="btn btn-sm btn-outline-success" onclick="acknowledgeAlert({{ alert.id }})">
<i class="bi bi-check"></i> Ack
</button>
{% else %}
<small class="text-muted" title="Acknowledged by {{ alert.acknowledged_by }} at {{ alert.acknowledged_at.strftime('%Y-%m-%d %H:%M') }}">
By: {{ alert.acknowledged_by }}
</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<nav aria-label="Alert pagination" class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('main.alerts', page=pagination.prev_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
Previous
</a>
</li>
{% for page_num in pagination.iter_pages(left_edge=1, left_current=1, right_current=2, right_edge=1) %}
{% if page_num %}
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('main.alerts', page=page_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('main.alerts', page=pagination.next_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
Next
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
<h5 class="mt-3">No alerts found</h5>
<p>Alerts will appear here when scan results trigger alert rules.</p>
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary mt-3">
<i class="bi bi-plus-circle"></i> Configure Alert Rules
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<script>
function acknowledgeAlert(alertId) {
if (!confirm('Acknowledge this alert?')) {
return;
}
fetch(`/api/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('api_key') || ''
},
body: JSON.stringify({
acknowledged_by: 'web_user'
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Failed to acknowledge alert: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to acknowledge alert');
});
}
</script>
{% endblock %}

View File

@@ -57,6 +57,16 @@
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.configs') }}">Configs</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.endpoint and 'alert' in request.endpoint %}active{% endif %}"
href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown">
Alerts
</a>
<ul class="dropdown-menu" aria-labelledby="alertsDropdown">
<li><a class="dropdown-item" href="{{ url_for('main.alerts') }}">Alert History</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.alert_rules') }}">Alert Rules</a></li>
</ul>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">

View File

@@ -53,6 +53,46 @@ class PaginatedResult:
"""Get next page number."""
return self.page + 1 if self.has_next else None
@property
def prev_num(self) -> int:
"""Alias for prev_page (Flask-SQLAlchemy compatibility)."""
return self.prev_page
@property
def next_num(self) -> int:
"""Alias for next_num (Flask-SQLAlchemy compatibility)."""
return self.next_page
def iter_pages(self, left_edge=1, left_current=1, right_current=2, right_edge=1):
"""
Generate page numbers for pagination display.
Yields page numbers and None for gaps, compatible with Flask-SQLAlchemy's
pagination.iter_pages() method.
Args:
left_edge: Number of pages to show on the left edge
left_current: Number of pages to show left of current page
right_current: Number of pages to show right of current page
right_edge: Number of pages to show on the right edge
Yields:
int or None: Page number or None for gaps
Example:
For 100 pages, current page 50:
Yields: 1, None, 48, 49, 50, 51, 52, None, 100
"""
last = 0
for num in range(1, self.pages + 1):
if num <= left_edge or \
(num > self.page - left_current - 1 and num < self.page + right_current) or \
num > self.pages - right_edge:
if last + 1 != num:
yield None
yield num
last = num
def to_dict(self) -> Dict[str, Any]:
"""
Convert to dictionary for API responses.