adding phase 5 init framework, added deployment ease scripts
This commit is contained in:
15
.env.example
15
.env.example
@@ -28,9 +28,10 @@ DATABASE_URL=sqlite:////app/data/sneakyscanner.db
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
|
||||
# SNEAKYSCANNER_ENCRYPTION_KEY: Used for encrypting sensitive settings in database
|
||||
# IMPORTANT: Change this to a random string in production!
|
||||
# IMPORTANT: Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
# Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
SNEAKYSCANNER_ENCRYPTION_KEY=your-encryption-key-here
|
||||
# Example: N3RhbGx5VmFsaWRGZXJuZXRLZXlIZXJlMTIzNDU2Nzg5MA==
|
||||
SNEAKYSCANNER_ENCRYPTION_KEY=
|
||||
|
||||
# ================================
|
||||
# CORS Configuration
|
||||
@@ -57,8 +58,10 @@ SCHEDULER_EXECUTORS=2
|
||||
SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES=3
|
||||
|
||||
# ================================
|
||||
# Optional: Application Password
|
||||
# Initial Password (First Run)
|
||||
# ================================
|
||||
# If you want to set the application password via environment variable
|
||||
# Otherwise, set it via init_db.py --password
|
||||
# APP_PASSWORD=your-password-here
|
||||
# Password used for database initialization on first run
|
||||
# This will be set as the application login password
|
||||
# Leave blank to auto-generate a random password (saved to ./logs/admin_password.txt)
|
||||
# IMPORTANT: Change this after first login!
|
||||
INITIAL_PASSWORD=
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,11 @@ output/
|
||||
data/
|
||||
logs/
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
admin_password.txt
|
||||
logs/admin_password.txt
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -39,12 +39,13 @@ COPY app/web/ ./web/
|
||||
COPY app/migrations/ ./migrations/
|
||||
COPY app/alembic.ini .
|
||||
COPY app/init_db.py .
|
||||
COPY app/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
# Create required directories
|
||||
RUN mkdir -p /app/output /app/logs
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x /app/src/scanner.py /app/init_db.py
|
||||
RUN chmod +x /app/src/scanner.py /app/init_db.py /docker-entrypoint.sh
|
||||
|
||||
# Force Python unbuffered output
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
34
README.md
34
README.md
@@ -28,6 +28,28 @@ A comprehensive network scanning and infrastructure monitoring platform with web
|
||||
|
||||
### Web Application (Recommended)
|
||||
|
||||
**Easy Setup (One Command):**
|
||||
|
||||
```bash
|
||||
# 1. Clone repository
|
||||
git clone <repository-url>
|
||||
cd SneakyScan
|
||||
|
||||
# 2. Run setup script
|
||||
./setup.sh
|
||||
|
||||
# 3. Access web interface at http://localhost:5000
|
||||
```
|
||||
|
||||
The setup script will:
|
||||
- Generate secure keys automatically
|
||||
- Create required directories
|
||||
- Build and start the Docker containers
|
||||
- Initialize the database on first run
|
||||
- Display your login credentials
|
||||
|
||||
**Manual Setup (Alternative):**
|
||||
|
||||
```bash
|
||||
# 1. Clone repository
|
||||
git clone <repository-url>
|
||||
@@ -35,16 +57,12 @@ cd SneakyScan
|
||||
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env and set SECRET_KEY and SNEAKYSCANNER_ENCRYPTION_KEY
|
||||
# Edit .env and set SECRET_KEY, SNEAKYSCANNER_ENCRYPTION_KEY, and INITIAL_PASSWORD
|
||||
|
||||
# 3. Build and start
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
# 3. Build and start (database auto-initializes on first run)
|
||||
docker compose up --build -d
|
||||
|
||||
# 4. Initialize database
|
||||
docker compose run --rm init-db --password "YourSecurePassword"
|
||||
|
||||
# 5. Access web interface
|
||||
# 4. Access web interface
|
||||
# Open http://localhost:5000
|
||||
```
|
||||
|
||||
|
||||
80
app/docker-entrypoint.sh
Normal file
80
app/docker-entrypoint.sh
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# SneakyScanner Docker Entrypoint Script
|
||||
# This script ensures the database is initialized before starting the Flask app
|
||||
|
||||
DB_PATH="${DATABASE_URL#sqlite:///}" # Extract path from sqlite:////app/data/sneakyscanner.db
|
||||
DB_DIR=$(dirname "$DB_PATH")
|
||||
INIT_MARKER="$DB_DIR/.db_initialized"
|
||||
PASSWORD_FILE="/app/logs/admin_password.txt" # Save to logs dir (mounted, no permission issues)
|
||||
|
||||
echo "=== SneakyScanner Startup ==="
|
||||
echo "Database path: $DB_PATH"
|
||||
echo "Database directory: $DB_DIR"
|
||||
|
||||
# Ensure database directory exists
|
||||
mkdir -p "$DB_DIR"
|
||||
|
||||
# Check if this is the first run (database doesn't exist or not initialized)
|
||||
if [ ! -f "$DB_PATH" ] || [ ! -f "$INIT_MARKER" ]; then
|
||||
echo ""
|
||||
echo "=== First Run Detected ==="
|
||||
echo "Initializing database..."
|
||||
|
||||
# Set default password from environment or generate a random one
|
||||
if [ -z "$INITIAL_PASSWORD" ]; then
|
||||
echo "INITIAL_PASSWORD not set, generating random password..."
|
||||
# Generate a 32-character alphanumeric password
|
||||
INITIAL_PASSWORD=$(cat /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||
# Ensure logs directory exists
|
||||
mkdir -p /app/logs
|
||||
echo "$INITIAL_PASSWORD" > "$PASSWORD_FILE"
|
||||
echo "✓ Random password generated and saved to: ./logs/admin_password.txt"
|
||||
SAVE_PASSWORD_MESSAGE=true
|
||||
fi
|
||||
|
||||
# Run database initialization
|
||||
python3 /app/init_db.py \
|
||||
--db-url "$DATABASE_URL" \
|
||||
--password "$INITIAL_PASSWORD" \
|
||||
--no-migrations \
|
||||
--force
|
||||
|
||||
# Create marker file to indicate successful initialization
|
||||
if [ $? -eq 0 ]; then
|
||||
touch "$INIT_MARKER"
|
||||
echo "✓ Database initialized successfully"
|
||||
echo ""
|
||||
echo "=== IMPORTANT ==="
|
||||
if [ "$SAVE_PASSWORD_MESSAGE" = "true" ]; then
|
||||
echo "Login password saved to: ./logs/admin_password.txt"
|
||||
echo "Password: $INITIAL_PASSWORD"
|
||||
else
|
||||
echo "Login password: $INITIAL_PASSWORD"
|
||||
fi
|
||||
echo "Please change this password after logging in!"
|
||||
echo "=================="
|
||||
echo ""
|
||||
else
|
||||
echo "✗ Database initialization failed!"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Database already initialized, skipping init..."
|
||||
fi
|
||||
|
||||
# Apply any pending migrations (if using migrations in future)
|
||||
if [ -f "/app/alembic.ini" ]; then
|
||||
echo "Checking for pending migrations..."
|
||||
# Uncomment when ready to use migrations:
|
||||
# alembic upgrade head
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Starting Flask Application ==="
|
||||
echo "Flask will be available at http://localhost:5000"
|
||||
echo ""
|
||||
|
||||
# Execute the main application
|
||||
exec "$@"
|
||||
118
app/init_db.py
118
app/init_db.py
@@ -23,11 +23,112 @@ from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from web.models import Base
|
||||
from web.models import Base, AlertRule
|
||||
from web.utils.settings import PasswordManager, SettingsManager
|
||||
|
||||
|
||||
def init_default_alert_rules(session):
|
||||
"""
|
||||
Create default alert rules for Phase 5.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
"""
|
||||
print("Initializing default alert rules...")
|
||||
|
||||
# Check if alert rules already exist
|
||||
existing_rules = session.query(AlertRule).count()
|
||||
if existing_rules > 0:
|
||||
print(f" Alert rules already exist ({existing_rules} rules), skipping...")
|
||||
return
|
||||
|
||||
default_rules = [
|
||||
{
|
||||
'name': 'Unexpected Port Detection',
|
||||
'rule_type': 'unexpected_port',
|
||||
'enabled': True,
|
||||
'threshold': None,
|
||||
'email_enabled': False,
|
||||
'webhook_enabled': False,
|
||||
'severity': 'warning',
|
||||
'filter_conditions': None,
|
||||
'config_file': None
|
||||
},
|
||||
{
|
||||
'name': 'Drift Detection',
|
||||
'rule_type': 'drift_detection',
|
||||
'enabled': True,
|
||||
'threshold': None, # No threshold means alert on any drift
|
||||
'email_enabled': False,
|
||||
'webhook_enabled': False,
|
||||
'severity': 'info',
|
||||
'filter_conditions': None,
|
||||
'config_file': None
|
||||
},
|
||||
{
|
||||
'name': 'Certificate Expiry Warning',
|
||||
'rule_type': 'cert_expiry',
|
||||
'enabled': True,
|
||||
'threshold': 30, # Alert when certs expire in 30 days
|
||||
'email_enabled': False,
|
||||
'webhook_enabled': False,
|
||||
'severity': 'warning',
|
||||
'filter_conditions': None,
|
||||
'config_file': None
|
||||
},
|
||||
{
|
||||
'name': 'Weak TLS Detection',
|
||||
'rule_type': 'weak_tls',
|
||||
'enabled': True,
|
||||
'threshold': None,
|
||||
'email_enabled': False,
|
||||
'webhook_enabled': False,
|
||||
'severity': 'warning',
|
||||
'filter_conditions': None,
|
||||
'config_file': None
|
||||
},
|
||||
{
|
||||
'name': 'Host Down Detection',
|
||||
'rule_type': 'ping_failed',
|
||||
'enabled': True,
|
||||
'threshold': None,
|
||||
'email_enabled': False,
|
||||
'webhook_enabled': False,
|
||||
'severity': 'critical',
|
||||
'filter_conditions': None,
|
||||
'config_file': None
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
for rule_data in default_rules:
|
||||
rule = AlertRule(
|
||||
name=rule_data['name'],
|
||||
rule_type=rule_data['rule_type'],
|
||||
enabled=rule_data['enabled'],
|
||||
threshold=rule_data['threshold'],
|
||||
email_enabled=rule_data['email_enabled'],
|
||||
webhook_enabled=rule_data['webhook_enabled'],
|
||||
severity=rule_data['severity'],
|
||||
filter_conditions=rule_data['filter_conditions'],
|
||||
config_file=rule_data['config_file'],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
session.add(rule)
|
||||
print(f" ✓ Created rule: {rule.name}")
|
||||
|
||||
session.commit()
|
||||
print(f"✓ Created {len(default_rules)} default alert rules")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create default alert rules: {e}")
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def init_database(db_url: str = "sqlite:///./sneakyscanner.db", run_migrations: bool = True):
|
||||
"""
|
||||
Initialize the database schema and settings.
|
||||
@@ -78,6 +179,10 @@ def init_database(db_url: str = "sqlite:///./sneakyscanner.db", run_migrations:
|
||||
settings_manager = SettingsManager(session)
|
||||
settings_manager.init_defaults()
|
||||
print("✓ Default settings initialized")
|
||||
|
||||
# Initialize default alert rules
|
||||
init_default_alert_rules(session)
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to initialize settings: {e}")
|
||||
session.rollback()
|
||||
@@ -164,6 +269,9 @@ Examples:
|
||||
# Use custom database URL
|
||||
python3 init_db.py --db-url postgresql://user:pass@localhost/sneakyscanner
|
||||
|
||||
# Force initialization without prompting (for Docker/scripts)
|
||||
python3 init_db.py --force --password mysecret
|
||||
|
||||
# Verify existing database
|
||||
python3 init_db.py --verify-only
|
||||
"""
|
||||
@@ -192,6 +300,12 @@ Examples:
|
||||
help='Create tables directly instead of using migrations'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Force initialization without prompting (for non-interactive environments)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check if database already exists
|
||||
@@ -200,7 +314,7 @@ Examples:
|
||||
db_path = args.db_url.replace('sqlite:///', '')
|
||||
db_exists = Path(db_path).exists()
|
||||
|
||||
if db_exists and not args.verify_only:
|
||||
if db_exists and not args.verify_only and not args.force:
|
||||
response = input(f"\nDatabase already exists at {db_path}. Reinitialize? (y/N): ")
|
||||
if response.lower() != 'y':
|
||||
print("Aborting.")
|
||||
|
||||
120
app/migrations/versions/004_add_alert_rule_enhancements.py
Normal file
120
app/migrations/versions/004_add_alert_rule_enhancements.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Add enhanced alert features for Phase 5
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2025-11-18
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic
|
||||
revision = '004'
|
||||
down_revision = '003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Add enhancements for Phase 5 Alert Rule Engine:
|
||||
- Enhanced alert_rules fields
|
||||
- Enhanced alerts fields
|
||||
- New webhooks table
|
||||
- New webhook_delivery_log table
|
||||
"""
|
||||
|
||||
# Enhance alert_rules table
|
||||
with op.batch_alter_table('alert_rules') as batch_op:
|
||||
batch_op.add_column(sa.Column('name', sa.String(255), nullable=True, comment='User-friendly rule name'))
|
||||
batch_op.add_column(sa.Column('webhook_enabled', sa.Boolean(), nullable=False, server_default='0', comment='Whether to send webhooks for this rule'))
|
||||
batch_op.add_column(sa.Column('severity', sa.String(20), nullable=True, comment='Alert severity level (critical, warning, info)'))
|
||||
batch_op.add_column(sa.Column('filter_conditions', sa.Text(), nullable=True, comment='JSON filter conditions for the rule'))
|
||||
batch_op.add_column(sa.Column('config_file', sa.String(255), nullable=True, comment='Optional: specific config file this rule applies to'))
|
||||
batch_op.add_column(sa.Column('updated_at', sa.DateTime(), nullable=True, comment='Last update timestamp'))
|
||||
|
||||
# Enhance alerts table
|
||||
with op.batch_alter_table('alerts') as batch_op:
|
||||
batch_op.add_column(sa.Column('rule_id', sa.Integer(), nullable=True, comment='Associated alert rule'))
|
||||
batch_op.add_column(sa.Column('webhook_sent', sa.Boolean(), nullable=False, server_default='0', comment='Whether webhook was sent'))
|
||||
batch_op.add_column(sa.Column('webhook_sent_at', sa.DateTime(), nullable=True, comment='When webhook was sent'))
|
||||
batch_op.add_column(sa.Column('acknowledged', sa.Boolean(), nullable=False, server_default='0', comment='Whether alert was acknowledged'))
|
||||
batch_op.add_column(sa.Column('acknowledged_at', sa.DateTime(), nullable=True, comment='When alert was acknowledged'))
|
||||
batch_op.add_column(sa.Column('acknowledged_by', sa.String(255), nullable=True, comment='User who acknowledged the alert'))
|
||||
batch_op.create_foreign_key('fk_alerts_rule_id', 'alert_rules', ['rule_id'], ['id'])
|
||||
batch_op.create_index('idx_alerts_rule_id', ['rule_id'])
|
||||
batch_op.create_index('idx_alerts_acknowledged', ['acknowledged'])
|
||||
|
||||
# Create webhooks table
|
||||
op.create_table('webhooks',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False, comment='Webhook name'),
|
||||
sa.Column('url', sa.Text(), nullable=False, comment='Webhook URL'),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default='1', comment='Whether webhook is enabled'),
|
||||
sa.Column('auth_type', sa.String(20), nullable=True, comment='Authentication type: none, bearer, basic, custom'),
|
||||
sa.Column('auth_token', sa.Text(), nullable=True, comment='Encrypted authentication token'),
|
||||
sa.Column('custom_headers', sa.Text(), nullable=True, comment='JSON custom headers'),
|
||||
sa.Column('alert_types', sa.Text(), nullable=True, comment='JSON array of alert types to trigger on'),
|
||||
sa.Column('severity_filter', sa.Text(), nullable=True, comment='JSON array of severities to trigger on'),
|
||||
sa.Column('timeout', sa.Integer(), nullable=True, server_default='10', comment='Request timeout in seconds'),
|
||||
sa.Column('retry_count', sa.Integer(), nullable=True, server_default='3', comment='Number of retry attempts'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create webhook_delivery_log table
|
||||
op.create_table('webhook_delivery_log',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('webhook_id', sa.Integer(), nullable=False, comment='Associated webhook'),
|
||||
sa.Column('alert_id', sa.Integer(), nullable=False, comment='Associated alert'),
|
||||
sa.Column('status', sa.String(20), nullable=True, comment='Delivery status: success, failed, retrying'),
|
||||
sa.Column('response_code', sa.Integer(), nullable=True, comment='HTTP response code'),
|
||||
sa.Column('response_body', sa.Text(), nullable=True, comment='Response body from webhook'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True, comment='Error message if failed'),
|
||||
sa.Column('attempt_number', sa.Integer(), nullable=True, comment='Which attempt this was'),
|
||||
sa.Column('delivered_at', sa.DateTime(), nullable=False, comment='Delivery timestamp'),
|
||||
sa.ForeignKeyConstraint(['webhook_id'], ['webhooks.id'], ),
|
||||
sa.ForeignKeyConstraint(['alert_id'], ['alerts.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for webhook_delivery_log
|
||||
op.create_index('idx_webhook_delivery_alert_id', 'webhook_delivery_log', ['alert_id'])
|
||||
op.create_index('idx_webhook_delivery_webhook_id', 'webhook_delivery_log', ['webhook_id'])
|
||||
op.create_index('idx_webhook_delivery_status', 'webhook_delivery_log', ['status'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove Phase 5 alert enhancements."""
|
||||
|
||||
# Drop webhook_delivery_log table and its indexes
|
||||
op.drop_index('idx_webhook_delivery_status', table_name='webhook_delivery_log')
|
||||
op.drop_index('idx_webhook_delivery_webhook_id', table_name='webhook_delivery_log')
|
||||
op.drop_index('idx_webhook_delivery_alert_id', table_name='webhook_delivery_log')
|
||||
op.drop_table('webhook_delivery_log')
|
||||
|
||||
# Drop webhooks table
|
||||
op.drop_table('webhooks')
|
||||
|
||||
# Remove enhancements from alerts table
|
||||
with op.batch_alter_table('alerts') as batch_op:
|
||||
batch_op.drop_index('idx_alerts_acknowledged')
|
||||
batch_op.drop_index('idx_alerts_rule_id')
|
||||
batch_op.drop_constraint('fk_alerts_rule_id', type_='foreignkey')
|
||||
batch_op.drop_column('acknowledged_by')
|
||||
batch_op.drop_column('acknowledged_at')
|
||||
batch_op.drop_column('acknowledged')
|
||||
batch_op.drop_column('webhook_sent_at')
|
||||
batch_op.drop_column('webhook_sent')
|
||||
batch_op.drop_column('rule_id')
|
||||
|
||||
# Remove enhancements from alert_rules table
|
||||
with op.batch_alter_table('alert_rules') as batch_op:
|
||||
batch_op.drop_column('updated_at')
|
||||
batch_op.drop_column('config_file')
|
||||
batch_op.drop_column('filter_conditions')
|
||||
batch_op.drop_column('severity')
|
||||
batch_op.drop_column('webhook_enabled')
|
||||
batch_op.drop_column('name')
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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}')>"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
490
app/web/services/alert_service.py
Normal file
490
app/web/services/alert_service.py
Normal 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()
|
||||
)
|
||||
474
app/web/templates/alert_rules.html
Normal file
474
app/web/templates/alert_rules.html
Normal 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 %}
|
||||
269
app/web/templates/alerts.html
Normal file
269
app/web/templates/alerts.html
Normal 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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,9 +5,9 @@ services:
|
||||
build: .
|
||||
image: sneakyscanner:latest
|
||||
container_name: sneakyscanner-web
|
||||
# Override entrypoint to run Flask app instead of scanner
|
||||
entrypoint: ["python3", "-u"]
|
||||
command: ["-m", "web.app"]
|
||||
# Use entrypoint script that auto-initializes database on first run
|
||||
entrypoint: ["/docker-entrypoint.sh"]
|
||||
command: ["python3", "-u", "-m", "web.app"]
|
||||
# Note: Using host network mode for scanner capabilities, so no port mapping needed
|
||||
# The Flask app will be accessible at http://localhost:5000
|
||||
volumes:
|
||||
@@ -28,7 +28,10 @@ services:
|
||||
- FLASK_PORT=5000
|
||||
# Database configuration (SQLite in mounted volume for persistence)
|
||||
- DATABASE_URL=sqlite:////app/data/sneakyscanner.db
|
||||
# Initial password for first run (leave empty to auto-generate)
|
||||
- INITIAL_PASSWORD=${INITIAL_PASSWORD:-}
|
||||
# Security settings
|
||||
# IMPORTANT: Set these in .env file or the application will fail to start!
|
||||
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
|
||||
- SNEAKYSCANNER_ENCRYPTION_KEY=${SNEAKYSCANNER_ENCRYPTION_KEY:-}
|
||||
# Optional: CORS origins (comma-separated)
|
||||
|
||||
@@ -74,6 +74,31 @@ docker compose version
|
||||
|
||||
For users who want to get started immediately with the web application:
|
||||
|
||||
**Option 1: Automated Setup (Recommended)**
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repository-url>
|
||||
cd SneakyScan
|
||||
|
||||
# 2. Run the setup script
|
||||
./setup.sh
|
||||
|
||||
# 3. Access the web interface
|
||||
# Open browser to: http://localhost:5000
|
||||
# Login with password from ./admin_password.txt or ./logs/admin_password.txt
|
||||
```
|
||||
|
||||
The setup script automatically:
|
||||
- Generates secure random keys (SECRET_KEY, ENCRYPTION_KEY)
|
||||
- Prompts for password or generates a random one
|
||||
- Creates required directories
|
||||
- Builds Docker image
|
||||
- Starts the application
|
||||
- Auto-initializes database on first run
|
||||
|
||||
**Option 2: Manual Setup**
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone <repository-url>
|
||||
@@ -82,18 +107,17 @@ cd SneakyScan
|
||||
# 2. Create environment file
|
||||
cp .env.example .env
|
||||
# Edit .env and set SECRET_KEY and SNEAKYSCANNER_ENCRYPTION_KEY
|
||||
# Optionally set INITIAL_PASSWORD (leave blank for auto-generation)
|
||||
nano .env
|
||||
|
||||
# 3. Build the Docker image
|
||||
docker compose build
|
||||
# 3. Build and start (database auto-initializes on first run)
|
||||
docker compose up --build -d
|
||||
|
||||
# 4. Initialize the database and set password
|
||||
docker compose run --rm init-db --password "YourSecurePassword"
|
||||
# 4. Check logs for auto-generated password (if not set in .env)
|
||||
docker compose logs web | grep "Password"
|
||||
# Or check: ./logs/admin_password.txt
|
||||
|
||||
# 5. Start the application
|
||||
docker compose up -d
|
||||
|
||||
# 6. Access the web interface
|
||||
# 5. Access the web interface
|
||||
# Open browser to: http://localhost:5000
|
||||
```
|
||||
|
||||
@@ -126,7 +150,10 @@ SneakyScanner is configured via environment variables. The recommended approach
|
||||
cp .env.example .env
|
||||
|
||||
# Generate secure keys
|
||||
# SECRET_KEY: Flask session secret (64-character hex string)
|
||||
python3 -c "import secrets; print('SECRET_KEY=' + secrets.token_hex(32))" >> .env
|
||||
|
||||
# SNEAKYSCANNER_ENCRYPTION_KEY: Fernet key for database encryption (32 url-safe base64 bytes)
|
||||
python3 -c "from cryptography.fernet import Fernet; print('SNEAKYSCANNER_ENCRYPTION_KEY=' + Fernet.generate_key().decode())" >> .env
|
||||
|
||||
# Edit other settings as needed
|
||||
@@ -142,6 +169,7 @@ nano .env
|
||||
| `SECRET_KEY` | Flask session secret (change in production!) | `dev-secret-key-change-in-production` | **Yes** |
|
||||
| `SNEAKYSCANNER_ENCRYPTION_KEY` | Encryption key for sensitive settings | (empty) | **Yes** |
|
||||
| `DATABASE_URL` | SQLite database path | `sqlite:////app/data/sneakyscanner.db` | Yes |
|
||||
| `INITIAL_PASSWORD` | Password for first-run initialization (leave empty to auto-generate) | (empty) | No |
|
||||
| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | `INFO` | No |
|
||||
| `SCHEDULER_EXECUTORS` | Number of concurrent scan threads | `2` | No |
|
||||
| `SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES` | Max instances of same job | `3` | No |
|
||||
@@ -223,28 +251,56 @@ docker images | grep sneakyscanner
|
||||
|
||||
### Step 4: Initialize Database
|
||||
|
||||
The database must be initialized before first use. The init-db service uses a profile, so you need to explicitly run it:
|
||||
**Automatic Initialization (Recommended)**
|
||||
|
||||
As of Phase 5, the database is automatically initialized on first run:
|
||||
|
||||
```bash
|
||||
# Just start the application
|
||||
docker compose up -d
|
||||
|
||||
# On first run, the entrypoint script will:
|
||||
# - Detect no existing database
|
||||
# - Generate a random password (if INITIAL_PASSWORD not set in .env)
|
||||
# - Save password to ./logs/admin_password.txt
|
||||
# - Initialize database schema
|
||||
# - Create default settings and alert rules
|
||||
# - Start the Flask application
|
||||
|
||||
# Check logs to see the auto-generated password
|
||||
docker compose logs web | grep "Password"
|
||||
|
||||
# Or view the password file
|
||||
cat logs/admin_password.txt
|
||||
```
|
||||
|
||||
**Manual Initialization (Advanced)**
|
||||
|
||||
You can still manually initialize the database if needed:
|
||||
|
||||
```bash
|
||||
# Initialize database and set application password
|
||||
docker compose -f docker-compose.yml run --rm init-db --password "YourSecurePassword"
|
||||
docker compose run --rm init-db --password "YourSecurePassword" --force
|
||||
|
||||
# The init-db command will:
|
||||
# - Create database schema
|
||||
# - Run all Alembic migrations
|
||||
# - Set the application password (bcrypt hashed)
|
||||
# - Create default settings with encryption
|
||||
# - Create default alert rules
|
||||
|
||||
# Verify database was created
|
||||
ls -lh data/sneakyscanner.db
|
||||
```
|
||||
|
||||
**Password Requirements:**
|
||||
**Password Management:**
|
||||
- Leave `INITIAL_PASSWORD` blank in `.env` for auto-generation
|
||||
- Auto-generated passwords are saved to `./logs/admin_password.txt`
|
||||
- For custom password, set `INITIAL_PASSWORD` in `.env`
|
||||
- Minimum 8 characters recommended
|
||||
- Use a strong, unique password
|
||||
- Store securely (password manager)
|
||||
|
||||
**Note**: The init-db service is defined with `profiles: [tools]` in docker-compose.yml, which means it won't start automatically with `docker compose up`.
|
||||
**Note**: The init-db service is defined with `profiles: [tools]` in docker-compose.yml, which means it won't start automatically with `docker compose up`. However, the web service now handles initialization automatically via the entrypoint script.
|
||||
|
||||
### Step 5: Verify Configuration
|
||||
|
||||
@@ -699,17 +755,25 @@ tail -f logs/sneakyscanner.log
|
||||
|
||||
```bash
|
||||
# Check logs for errors
|
||||
docker compose -f docker-compose.yml logs web
|
||||
docker compose logs web
|
||||
|
||||
# Common issues:
|
||||
# 1. Database not initialized - run init-db first
|
||||
# 2. Permission issues with volumes - check directory ownership
|
||||
# 3. Port 5000 already in use - change FLASK_PORT or stop conflicting service
|
||||
# 1. Permission issues with volumes - check directory ownership
|
||||
# 2. Port 5000 already in use - change FLASK_PORT or stop conflicting service
|
||||
# 3. Database initialization failed - check logs for specific error
|
||||
|
||||
# Check if database initialization is stuck
|
||||
docker compose logs web | grep -A 20 "First Run Detected"
|
||||
|
||||
# If initialization failed, clean up and retry
|
||||
docker compose down
|
||||
rm -rf data/.db_initialized # Remove marker file
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Database Initialization Fails
|
||||
|
||||
**Problem**: `init_db.py` fails with errors
|
||||
**Problem**: Automatic database initialization fails on first run
|
||||
|
||||
```bash
|
||||
# Check database directory permissions
|
||||
@@ -718,12 +782,37 @@ ls -la data/
|
||||
# Fix permissions if needed
|
||||
sudo chown -R $USER:$USER data/
|
||||
|
||||
# Verify SQLite is accessible
|
||||
sqlite3 data/sneakyscanner.db "SELECT 1;" 2>&1
|
||||
# View initialization logs
|
||||
docker compose logs web | grep -A 50 "Initializing database"
|
||||
|
||||
# Remove corrupted database and reinitialize
|
||||
rm data/sneakyscanner.db
|
||||
docker compose -f docker-compose.yml run --rm init-db --password "YourPassword"
|
||||
# Clean up and retry initialization
|
||||
docker compose down
|
||||
rm -rf data/sneakyscanner.db data/.db_initialized
|
||||
docker compose up -d
|
||||
|
||||
# Or manually initialize with specific password
|
||||
docker compose down
|
||||
rm -rf data/sneakyscanner.db data/.db_initialized
|
||||
docker compose run --rm init-db --password "YourPassword" --force
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Can't Find Password File**
|
||||
|
||||
**Problem**: Password file not created or can't be found
|
||||
|
||||
```bash
|
||||
# Check both possible locations
|
||||
cat admin_password.txt # Created by setup.sh
|
||||
cat logs/admin_password.txt # Created by Docker entrypoint
|
||||
|
||||
# Check container logs for password
|
||||
docker compose logs web | grep -i password
|
||||
|
||||
# If password file is missing, manually set one
|
||||
docker compose down
|
||||
docker compose run --rm init-db --password "YourNewPassword" --force
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Scans Fail with "Permission Denied"
|
||||
|
||||
150
setup.sh
Executable file
150
setup.sh
Executable file
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# SneakyScanner First-Run Setup Script
|
||||
# This script helps you get started quickly with SneakyScanner
|
||||
|
||||
echo "================================================"
|
||||
echo " SneakyScanner - First Run Setup"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Function to generate random key for Flask SECRET_KEY
|
||||
generate_secret_key() {
|
||||
openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
}
|
||||
|
||||
# Function to generate Fernet encryption key (32 url-safe base64-encoded bytes)
|
||||
generate_fernet_key() {
|
||||
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>/dev/null || \
|
||||
openssl rand -base64 32 | head -c 44
|
||||
}
|
||||
|
||||
# Check if .env exists
|
||||
if [ -f .env ]; then
|
||||
echo "✓ .env file already exists"
|
||||
read -p "Do you want to regenerate it? (y/N): " REGENERATE
|
||||
if [ "$REGENERATE" != "y" ] && [ "$REGENERATE" != "Y" ]; then
|
||||
echo "Skipping .env creation..."
|
||||
SKIP_ENV=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create or update .env
|
||||
if [ "$SKIP_ENV" != "true" ]; then
|
||||
echo ""
|
||||
echo "Creating .env file..."
|
||||
|
||||
# Generate secure keys
|
||||
SECRET_KEY=$(generate_secret_key)
|
||||
ENCRYPTION_KEY=$(generate_fernet_key)
|
||||
|
||||
# Ask for initial password
|
||||
echo ""
|
||||
echo "Set an initial password for the web interface:"
|
||||
read -s -p "Password (or press Enter to generate random password): " INITIAL_PASSWORD
|
||||
echo ""
|
||||
|
||||
if [ -z "$INITIAL_PASSWORD" ]; then
|
||||
echo "Generating random password..."
|
||||
# Generate a 32-character alphanumeric password
|
||||
INITIAL_PASSWORD=$(cat /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||
# Save password to file in project root (avoid permission issues with mounted volumes)
|
||||
echo "$INITIAL_PASSWORD" > admin_password.txt
|
||||
echo "✓ Random password generated and saved to: ./admin_password.txt"
|
||||
PASSWORD_SAVED=true
|
||||
fi
|
||||
|
||||
# Create .env file
|
||||
cat > .env << EOF
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
FLASK_DEBUG=false
|
||||
|
||||
# Security Keys (randomly generated)
|
||||
SECRET_KEY=$SECRET_KEY
|
||||
SNEAKYSCANNER_ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
|
||||
# Initial Password
|
||||
INITIAL_PASSWORD=$INITIAL_PASSWORD
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=sqlite:////app/data/sneakyscanner.db
|
||||
|
||||
# Optional: Logging
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Optional: CORS (comma-separated origins, or * for all)
|
||||
CORS_ORIGINS=*
|
||||
EOF
|
||||
|
||||
echo "✓ .env file created with secure keys"
|
||||
fi
|
||||
|
||||
# Create required directories
|
||||
echo ""
|
||||
echo "Creating required directories..."
|
||||
mkdir -p data logs output configs
|
||||
echo "✓ Directories created"
|
||||
|
||||
# Check if Docker is running
|
||||
echo ""
|
||||
echo "Checking Docker..."
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "✗ Docker is not running or not installed"
|
||||
echo "Please install Docker and start the Docker daemon"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Docker is running"
|
||||
|
||||
# Build and start
|
||||
echo ""
|
||||
echo "Building and starting SneakyScanner..."
|
||||
echo "This may take a few minutes on first run..."
|
||||
echo ""
|
||||
|
||||
docker compose build
|
||||
|
||||
echo ""
|
||||
echo "Starting SneakyScanner..."
|
||||
docker compose up -d
|
||||
|
||||
# Wait for service to be healthy
|
||||
echo ""
|
||||
echo "Waiting for application to start..."
|
||||
sleep 5
|
||||
|
||||
# Check if container is running
|
||||
if docker ps | grep -q sneakyscanner-web; then
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo " ✓ SneakyScanner is Running!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "Web Interface: http://localhost:5000"
|
||||
echo ""
|
||||
echo "Login with:"
|
||||
if [ -z "$SKIP_ENV" ]; then
|
||||
if [ "$PASSWORD_SAVED" = "true" ]; then
|
||||
echo " Password saved in: ./admin_password.txt"
|
||||
echo " Password: $INITIAL_PASSWORD"
|
||||
else
|
||||
echo " Password: $INITIAL_PASSWORD"
|
||||
fi
|
||||
else
|
||||
echo " Password: (check your .env file or ./admin_password.txt)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " docker compose logs -f # View logs"
|
||||
echo " docker compose stop # Stop the service"
|
||||
echo " docker compose restart # Restart the service"
|
||||
echo ""
|
||||
echo "⚠ IMPORTANT: Change your password after first login!"
|
||||
echo "================================================"
|
||||
else
|
||||
echo ""
|
||||
echo "✗ Container failed to start. Check logs with:"
|
||||
echo " docker compose logs"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user