Compare commits

...

6 Commits

47 changed files with 6920 additions and 345 deletions

View File

@@ -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
View File

@@ -9,6 +9,11 @@ output/
data/
logs/
# Environment and secrets
.env
admin_password.txt
logs/admin_password.txt
# Python
__pycache__/
*.py[cod]

View File

@@ -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

View File

@@ -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
View 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 "$@"

View File

@@ -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.")

View 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')

View File

@@ -0,0 +1,83 @@
"""Add webhook template support
Revision ID: 005
Revises: 004
Create Date: 2025-11-18
"""
from alembic import op
import sqlalchemy as sa
import json
# revision identifiers, used by Alembic
revision = '005'
down_revision = '004'
branch_labels = None
depends_on = None
# Default template that matches the current JSON payload structure
DEFAULT_TEMPLATE = """{
"event": "alert.created",
"alert": {
"id": {{ alert.id }},
"type": "{{ alert.type }}",
"severity": "{{ alert.severity }}",
"message": "{{ alert.message }}",
{% if alert.ip_address %}"ip_address": "{{ alert.ip_address }}",{% endif %}
{% if alert.port %}"port": {{ alert.port }},{% endif %}
"acknowledged": {{ alert.acknowledged|lower }},
"created_at": "{{ alert.created_at.isoformat() }}"
},
"scan": {
"id": {{ scan.id }},
"title": "{{ scan.title }}",
"timestamp": "{{ scan.timestamp.isoformat() }}",
"status": "{{ scan.status }}"
},
"rule": {
"id": {{ rule.id }},
"name": "{{ rule.name }}",
"type": "{{ rule.type }}",
"threshold": {{ rule.threshold if rule.threshold else 'null' }}
}
}"""
def upgrade():
"""
Add webhook template fields:
- template: Jinja2 template for payload
- template_format: Output format (json, text)
- content_type_override: Optional custom Content-Type
"""
# Add new columns to webhooks table
with op.batch_alter_table('webhooks') as batch_op:
batch_op.add_column(sa.Column('template', sa.Text(), nullable=True, comment='Jinja2 template for webhook payload'))
batch_op.add_column(sa.Column('template_format', sa.String(20), nullable=True, server_default='json', comment='Template output format: json, text'))
batch_op.add_column(sa.Column('content_type_override', sa.String(100), nullable=True, comment='Optional custom Content-Type header'))
# Populate existing webhooks with default template
# This ensures backward compatibility by converting existing webhooks to use the
# same JSON structure they're currently sending
connection = op.get_bind()
connection.execute(
sa.text("""
UPDATE webhooks
SET template = :template,
template_format = 'json'
WHERE template IS NULL
"""),
{"template": DEFAULT_TEMPLATE}
)
def downgrade():
"""Remove webhook template fields."""
with op.batch_alter_table('webhooks') as batch_op:
batch_op.drop_column('content_type_override')
batch_op.drop_column('template_format')
batch_op.drop_column('template')

View File

@@ -26,6 +26,9 @@ croniter==2.0.1
# Email Support (Phase 4)
Flask-Mail==0.9.1
# Webhook Support (Phase 5)
requests==2.31.0
# Configuration Management
python-dotenv==1.0.0

View File

@@ -1,197 +0,0 @@
#!/usr/bin/env python3
"""
Phase 1 validation script.
Validates that all Phase 1 deliverables are in place and code structure is correct.
Does not require dependencies to be installed.
"""
import ast
import os
import sys
from pathlib import Path
def validate_file_exists(file_path, description):
"""Check if a file exists."""
if Path(file_path).exists():
print(f"{description}: {file_path}")
return True
else:
print(f"{description} missing: {file_path}")
return False
def validate_directory_exists(dir_path, description):
"""Check if a directory exists."""
if Path(dir_path).is_dir():
print(f"{description}: {dir_path}")
return True
else:
print(f"{description} missing: {dir_path}")
return False
def validate_python_syntax(file_path):
"""Validate Python file syntax."""
try:
with open(file_path, 'r') as f:
ast.parse(f.read())
return True
except SyntaxError as e:
print(f" ✗ Syntax error in {file_path}: {e}")
return False
def main():
"""Run all validation checks."""
print("=" * 70)
print("SneakyScanner Phase 1 Validation")
print("=" * 70)
all_passed = True
# Check project structure
print("\n1. Project Structure:")
print("-" * 70)
structure_checks = [
("web/", "Web application directory"),
("web/api/", "API blueprints directory"),
("web/templates/", "Jinja2 templates directory"),
("web/static/", "Static files directory"),
("web/utils/", "Utility modules directory"),
("migrations/", "Alembic migrations directory"),
("migrations/versions/", "Migration versions directory"),
]
for path, desc in structure_checks:
if not validate_directory_exists(path, desc):
all_passed = False
# Check core files
print("\n2. Core Files:")
print("-" * 70)
core_files = [
("requirements-web.txt", "Web dependencies"),
("alembic.ini", "Alembic configuration"),
("init_db.py", "Database initialization script"),
("docker-compose-web.yml", "Docker Compose for web app"),
]
for path, desc in core_files:
if not validate_file_exists(path, desc):
all_passed = False
# Check Python modules
print("\n3. Python Modules:")
print("-" * 70)
python_modules = [
("web/__init__.py", "Web package init"),
("web/models.py", "SQLAlchemy models"),
("web/app.py", "Flask application factory"),
("web/utils/__init__.py", "Utils package init"),
("web/utils/settings.py", "Settings manager"),
("web/api/__init__.py", "API package init"),
("web/api/scans.py", "Scans API blueprint"),
("web/api/schedules.py", "Schedules API blueprint"),
("web/api/alerts.py", "Alerts API blueprint"),
("web/api/settings.py", "Settings API blueprint"),
("migrations/env.py", "Alembic environment"),
("migrations/script.py.mako", "Migration template"),
("migrations/versions/001_initial_schema.py", "Initial migration"),
]
for path, desc in python_modules:
exists = validate_file_exists(path, desc)
if exists:
# Skip syntax check for .mako templates (they're not pure Python)
if not path.endswith('.mako'):
if not validate_python_syntax(path):
all_passed = False
else:
print(f" (Skipped syntax check for template file)")
else:
all_passed = False
# Check models
print("\n4. Database Models (from models.py):")
print("-" * 70)
try:
# Read models.py and look for class definitions
with open('web/models.py', 'r') as f:
content = f.read()
tree = ast.parse(content)
models = []
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name != 'Base':
models.append(node.name)
expected_models = [
'Scan', 'ScanSite', 'ScanIP', 'ScanPort', 'ScanService',
'ScanCertificate', 'ScanTLSVersion', 'Schedule', 'Alert',
'AlertRule', 'Setting'
]
for model in expected_models:
if model in models:
print(f"✓ Model defined: {model}")
else:
print(f"✗ Model missing: {model}")
all_passed = False
except Exception as e:
print(f"✗ Failed to parse models.py: {e}")
all_passed = False
# Check API endpoints
print("\n5. API Blueprints:")
print("-" * 70)
blueprints = {
'web/api/scans.py': ['list_scans', 'get_scan', 'trigger_scan', 'delete_scan'],
'web/api/schedules.py': ['list_schedules', 'get_schedule', 'create_schedule'],
'web/api/alerts.py': ['list_alerts', 'list_alert_rules'],
'web/api/settings.py': ['get_settings', 'update_settings'],
}
for blueprint_file, expected_funcs in blueprints.items():
try:
with open(blueprint_file, 'r') as f:
content = f.read()
tree = ast.parse(content)
functions = [node.name for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)]
print(f"\n {blueprint_file}:")
for func in expected_funcs:
if func in functions:
print(f" ✓ Endpoint: {func}")
else:
print(f" ✗ Missing endpoint: {func}")
all_passed = False
except Exception as e:
print(f" ✗ Failed to parse {blueprint_file}: {e}")
all_passed = False
# Summary
print("\n" + "=" * 70)
if all_passed:
print("✓ All Phase 1 validation checks passed!")
print("\nNext steps:")
print("1. Install dependencies: pip install -r requirements-web.txt")
print("2. Initialize database: python3 init_db.py --password YOUR_PASSWORD")
print("3. Run Flask app: python3 -m web.app")
print("4. Test API: curl http://localhost:5000/api/settings/health")
return 0
else:
print("✗ Some validation checks failed. Please review errors above.")
return 1
if __name__ == '__main__':
sys.exit(main())

View File

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

View File

@@ -75,6 +75,12 @@ def update_settings():
'status': 'success',
'message': f'Updated {len(settings_dict)} settings'
})
except ValueError as e:
# Handle read-only setting attempts
return jsonify({
'status': 'error',
'message': str(e)
}), 403
except Exception as e:
current_app.logger.error(f"Failed to update settings: {e}")
return jsonify({
@@ -112,7 +118,8 @@ def get_setting(key):
return jsonify({
'status': 'success',
'key': key,
'value': value
'value': value,
'read_only': settings_manager._is_read_only(key)
})
except Exception as e:
current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
@@ -154,6 +161,12 @@ def update_setting(key):
'status': 'success',
'message': f'Setting "{key}" updated'
})
except ValueError as e:
# Handle read-only setting attempts
return jsonify({
'status': 'error',
'message': str(e)
}), 403
except Exception as e:
current_app.logger.error(f"Failed to update setting {key}: {e}")
return jsonify({
@@ -176,6 +189,14 @@ def delete_setting(key):
"""
try:
settings_manager = get_settings_manager()
# Prevent deletion of read-only settings
if settings_manager._is_read_only(key):
return jsonify({
'status': 'error',
'message': f'Setting "{key}" is read-only and cannot be deleted'
}), 403
deleted = settings_manager.delete(key)
if not deleted:

677
app/web/api/webhooks.py Normal file
View File

@@ -0,0 +1,677 @@
"""
Webhooks API blueprint.
Handles endpoints for managing webhook configurations and viewing delivery logs.
"""
import json
from datetime import datetime, timezone
from flask import Blueprint, jsonify, request, current_app
from web.auth.decorators import api_auth_required
from web.models import Webhook, WebhookDeliveryLog, Alert
from web.services.webhook_service import WebhookService
from web.services.template_service import get_template_service
bp = Blueprint('webhooks_api', __name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_webhooks():
"""
List all webhooks with optional filtering.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20)
enabled: Filter by enabled status (true/false)
Returns:
JSON response with webhooks list
"""
# Get query parameters
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100) # Max 100 items
enabled = request.args.get('enabled')
# Build query
query = current_app.db_session.query(Webhook)
# Apply enabled filter
if enabled is not None:
enabled_bool = enabled.lower() == 'true'
query = query.filter(Webhook.enabled == enabled_bool)
# Order by name
query = query.order_by(Webhook.name)
# Paginate
total = query.count()
webhooks = query.offset((page - 1) * per_page).limit(per_page).all()
# Format response
webhooks_data = []
for webhook in webhooks:
# Parse JSON fields
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
webhooks_data.append({
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'auth_token': '***ENCRYPTED***' if webhook.auth_token else None, # Mask sensitive data
'custom_headers': custom_headers,
'alert_types': alert_types,
'severity_filter': severity_filter,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'created_at': webhook.created_at.isoformat() if webhook.created_at else None,
'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None
})
return jsonify({
'webhooks': webhooks_data,
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page
})
@bp.route('/<int:webhook_id>', methods=['GET'])
@api_auth_required
def get_webhook(webhook_id):
"""
Get a specific webhook by ID.
Args:
webhook_id: Webhook ID
Returns:
JSON response with webhook details
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
# Parse JSON fields
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
return jsonify({
'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'auth_token': '***ENCRYPTED***' if webhook.auth_token else None,
'custom_headers': custom_headers,
'alert_types': alert_types,
'severity_filter': severity_filter,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'created_at': webhook.created_at.isoformat() if webhook.created_at else None,
'updated_at': webhook.updated_at.isoformat() if webhook.updated_at else None
}
})
@bp.route('', methods=['POST'])
@api_auth_required
def create_webhook():
"""
Create a new webhook.
Request body:
name: Webhook name (required)
url: Webhook URL (required)
enabled: Whether webhook is enabled (default: true)
auth_type: Authentication type (none, bearer, basic, custom)
auth_token: Authentication token (encrypted on storage)
custom_headers: JSON object with custom headers
alert_types: Array of alert types to filter
severity_filter: Array of severities to filter
timeout: Request timeout in seconds (default: 10)
retry_count: Number of retry attempts (default: 3)
template: Jinja2 template for custom payload (optional)
template_format: Template format - 'json' or 'text' (default: json)
content_type_override: Custom Content-Type header (optional)
Returns:
JSON response with created webhook
"""
data = request.get_json() or {}
# Validate required fields
if not data.get('name'):
return jsonify({
'status': 'error',
'message': 'name is required'
}), 400
if not data.get('url'):
return jsonify({
'status': 'error',
'message': 'url is required'
}), 400
# Validate auth_type
valid_auth_types = ['none', 'bearer', 'basic', 'custom']
auth_type = data.get('auth_type', 'none')
if auth_type not in valid_auth_types:
return jsonify({
'status': 'error',
'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
}), 400
# Validate template_format
valid_template_formats = ['json', 'text']
template_format = data.get('template_format', 'json')
if template_format not in valid_template_formats:
return jsonify({
'status': 'error',
'message': f'Invalid template_format. Must be one of: {", ".join(valid_template_formats)}'
}), 400
# Validate template if provided
template = data.get('template')
if template:
template_service = get_template_service()
is_valid, error_msg = template_service.validate_template(template, template_format)
if not is_valid:
return jsonify({
'status': 'error',
'message': f'Invalid template: {error_msg}'
}), 400
try:
webhook_service = WebhookService(current_app.db_session)
# Encrypt auth_token if provided
auth_token = None
if data.get('auth_token'):
auth_token = webhook_service._encrypt_value(data['auth_token'])
# Serialize JSON fields
alert_types = json.dumps(data['alert_types']) if data.get('alert_types') else None
severity_filter = json.dumps(data['severity_filter']) if data.get('severity_filter') else None
custom_headers = json.dumps(data['custom_headers']) if data.get('custom_headers') else None
# Create webhook
webhook = Webhook(
name=data['name'],
url=data['url'],
enabled=data.get('enabled', True),
auth_type=auth_type,
auth_token=auth_token,
custom_headers=custom_headers,
alert_types=alert_types,
severity_filter=severity_filter,
timeout=data.get('timeout', 10),
retry_count=data.get('retry_count', 3),
template=template,
template_format=template_format,
content_type_override=data.get('content_type_override'),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
current_app.db_session.add(webhook)
current_app.db_session.commit()
# Parse for response
alert_types_parsed = json.loads(alert_types) if alert_types else None
severity_filter_parsed = json.loads(severity_filter) if severity_filter else None
custom_headers_parsed = json.loads(custom_headers) if custom_headers else None
return jsonify({
'status': 'success',
'message': 'Webhook created successfully',
'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'alert_types': alert_types_parsed,
'severity_filter': severity_filter_parsed,
'custom_headers': custom_headers_parsed,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'template': webhook.template,
'template_format': webhook.template_format,
'content_type_override': webhook.content_type_override,
'created_at': webhook.created_at.isoformat()
}
}), 201
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to create webhook: {str(e)}'
}), 500
@bp.route('/<int:webhook_id>', methods=['PUT'])
@api_auth_required
def update_webhook(webhook_id):
"""
Update an existing webhook.
Args:
webhook_id: Webhook ID
Request body (all optional):
name: Webhook name
url: Webhook URL
enabled: Whether webhook is enabled
auth_type: Authentication type
auth_token: Authentication token
custom_headers: JSON object with custom headers
alert_types: Array of alert types
severity_filter: Array of severities
timeout: Request timeout
retry_count: Retry attempts
template: Jinja2 template for custom payload
template_format: Template format - 'json' or 'text'
content_type_override: Custom Content-Type header
Returns:
JSON response with update status
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
data = request.get_json() or {}
# Validate auth_type if provided
if 'auth_type' in data:
valid_auth_types = ['none', 'bearer', 'basic', 'custom']
if data['auth_type'] not in valid_auth_types:
return jsonify({
'status': 'error',
'message': f'Invalid auth_type. Must be one of: {", ".join(valid_auth_types)}'
}), 400
# Validate template_format if provided
if 'template_format' in data:
valid_template_formats = ['json', 'text']
if data['template_format'] not in valid_template_formats:
return jsonify({
'status': 'error',
'message': f'Invalid template_format. Must be one of: {", ".join(valid_template_formats)}'
}), 400
# Validate template if provided
if 'template' in data and data['template']:
template_format = data.get('template_format', webhook.template_format or 'json')
template_service = get_template_service()
is_valid, error_msg = template_service.validate_template(data['template'], template_format)
if not is_valid:
return jsonify({
'status': 'error',
'message': f'Invalid template: {error_msg}'
}), 400
try:
webhook_service = WebhookService(current_app.db_session)
# Update fields if provided
if 'name' in data:
webhook.name = data['name']
if 'url' in data:
webhook.url = data['url']
if 'enabled' in data:
webhook.enabled = data['enabled']
if 'auth_type' in data:
webhook.auth_type = data['auth_type']
if 'auth_token' in data:
# Encrypt new token
webhook.auth_token = webhook_service._encrypt_value(data['auth_token'])
if 'custom_headers' in data:
webhook.custom_headers = json.dumps(data['custom_headers']) if data['custom_headers'] else None
if 'alert_types' in data:
webhook.alert_types = json.dumps(data['alert_types']) if data['alert_types'] else None
if 'severity_filter' in data:
webhook.severity_filter = json.dumps(data['severity_filter']) if data['severity_filter'] else None
if 'timeout' in data:
webhook.timeout = data['timeout']
if 'retry_count' in data:
webhook.retry_count = data['retry_count']
if 'template' in data:
webhook.template = data['template']
if 'template_format' in data:
webhook.template_format = data['template_format']
if 'content_type_override' in data:
webhook.content_type_override = data['content_type_override']
webhook.updated_at = datetime.now(timezone.utc)
current_app.db_session.commit()
# Parse for response
alert_types = json.loads(webhook.alert_types) if webhook.alert_types else None
severity_filter = json.loads(webhook.severity_filter) if webhook.severity_filter else None
custom_headers = json.loads(webhook.custom_headers) if webhook.custom_headers else None
return jsonify({
'status': 'success',
'message': 'Webhook updated successfully',
'webhook': {
'id': webhook.id,
'name': webhook.name,
'url': webhook.url,
'enabled': webhook.enabled,
'auth_type': webhook.auth_type,
'alert_types': alert_types,
'severity_filter': severity_filter,
'custom_headers': custom_headers,
'timeout': webhook.timeout,
'retry_count': webhook.retry_count,
'template': webhook.template,
'template_format': webhook.template_format,
'content_type_override': webhook.content_type_override,
'updated_at': webhook.updated_at.isoformat()
}
})
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to update webhook: {str(e)}'
}), 500
@bp.route('/<int:webhook_id>', methods=['DELETE'])
@api_auth_required
def delete_webhook(webhook_id):
"""
Delete a webhook.
Args:
webhook_id: Webhook ID
Returns:
JSON response with deletion status
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
try:
# Delete webhook (delivery logs will be cascade deleted)
current_app.db_session.delete(webhook)
current_app.db_session.commit()
return jsonify({
'status': 'success',
'message': f'Webhook {webhook_id} deleted successfully'
})
except Exception as e:
current_app.db_session.rollback()
return jsonify({
'status': 'error',
'message': f'Failed to delete webhook: {str(e)}'
}), 500
@bp.route('/<int:webhook_id>/test', methods=['POST'])
@api_auth_required
def test_webhook(webhook_id):
"""
Send a test payload to a webhook.
Args:
webhook_id: Webhook ID
Returns:
JSON response with test result
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
# Test webhook delivery
webhook_service = WebhookService(current_app.db_session)
result = webhook_service.test_webhook(webhook_id)
return jsonify({
'status': 'success' if result['success'] else 'error',
'message': result['message'],
'status_code': result['status_code'],
'response_body': result.get('response_body')
})
@bp.route('/<int:webhook_id>/logs', methods=['GET'])
@api_auth_required
def get_webhook_logs(webhook_id):
"""
Get delivery logs for a specific webhook.
Args:
webhook_id: Webhook ID
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20)
status: Filter by status (success/failed)
Returns:
JSON response with delivery logs
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return jsonify({
'status': 'error',
'message': f'Webhook {webhook_id} not found'
}), 404
# Get query parameters
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
status_filter = request.args.get('status')
# Build query
query = current_app.db_session.query(WebhookDeliveryLog).filter(
WebhookDeliveryLog.webhook_id == webhook_id
)
# Apply status filter
if status_filter:
query = query.filter(WebhookDeliveryLog.status == status_filter)
# Order by most recent first
query = query.order_by(WebhookDeliveryLog.delivered_at.desc())
# Paginate
total = query.count()
logs = query.offset((page - 1) * per_page).limit(per_page).all()
# Format response
logs_data = []
for log in logs:
# Get alert info
alert = current_app.db_session.query(Alert).filter(Alert.id == log.alert_id).first()
logs_data.append({
'id': log.id,
'alert_id': log.alert_id,
'alert_type': alert.alert_type if alert else None,
'alert_message': alert.message if alert else None,
'status': log.status,
'response_code': log.response_code,
'response_body': log.response_body,
'error_message': log.error_message,
'attempt_number': log.attempt_number,
'delivered_at': log.delivered_at.isoformat() if log.delivered_at else None
})
return jsonify({
'webhook_id': webhook_id,
'webhook_name': webhook.name,
'logs': logs_data,
'total': total,
'page': page,
'per_page': per_page,
'pages': (total + per_page - 1) // per_page
})
@bp.route('/preview-template', methods=['POST'])
@api_auth_required
def preview_template():
"""
Preview a webhook template with sample data.
Request body:
template: Jinja2 template string (required)
template_format: Template format - 'json' or 'text' (default: json)
Returns:
JSON response with rendered template preview
"""
data = request.get_json() or {}
if not data.get('template'):
return jsonify({
'status': 'error',
'message': 'template is required'
}), 400
template = data['template']
template_format = data.get('template_format', 'json')
# Validate template format
if template_format not in ['json', 'text']:
return jsonify({
'status': 'error',
'message': 'Invalid template_format. Must be json or text'
}), 400
try:
template_service = get_template_service()
# Validate template
is_valid, error_msg = template_service.validate_template(template, template_format)
if not is_valid:
return jsonify({
'status': 'error',
'message': f'Template validation error: {error_msg}'
}), 400
# Render with sample data
rendered, error = template_service.render_test_payload(template, template_format)
if error:
return jsonify({
'status': 'error',
'message': f'Template rendering error: {error}'
}), 400
return jsonify({
'status': 'success',
'rendered': rendered,
'format': template_format
})
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Failed to preview template: {str(e)}'
}), 500
@bp.route('/template-presets', methods=['GET'])
@api_auth_required
def get_template_presets():
"""
Get list of available webhook template presets.
Returns:
JSON response with template presets
"""
import os
try:
# Load presets manifest
presets_file = os.path.join(
os.path.dirname(__file__),
'../templates/webhook_presets/presets.json'
)
with open(presets_file, 'r') as f:
presets_manifest = json.load(f)
# Load template contents for each preset
presets_dir = os.path.join(
os.path.dirname(__file__),
'../templates/webhook_presets'
)
for preset in presets_manifest:
template_file = os.path.join(presets_dir, preset['file'])
with open(template_file, 'r') as f:
preset['template'] = f.read()
# Remove file reference from response
del preset['file']
return jsonify({
'status': 'success',
'presets': presets_manifest
})
except FileNotFoundError as e:
return jsonify({
'status': 'error',
'message': f'Template presets not found: {str(e)}'
}), 500
except Exception as e:
return jsonify({
'status': 'error',
'message': f'Failed to load template presets: {str(e)}'
}), 500
# Health check endpoint
@bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for monitoring.
Returns:
JSON response with API health status
"""
return jsonify({
'status': 'healthy',
'api': 'webhooks',
'version': '1.0.0-phase5'
})

View File

@@ -95,6 +95,9 @@ def create_app(config: dict = None) -> Flask:
# Register error handlers
register_error_handlers(app)
# Register context processors
register_context_processors(app)
# Add request/response handlers
register_request_handlers(app)
@@ -328,11 +331,13 @@ def register_blueprints(app: Flask) -> None:
from web.api.scans import bp as scans_bp
from web.api.schedules import bp as schedules_bp
from web.api.alerts import bp as alerts_bp
from web.api.webhooks import bp as webhooks_api_bp
from web.api.settings import bp as settings_bp
from web.api.stats import bp as stats_bp
from web.api.configs import bp as configs_bp
from web.auth.routes import bp as auth_bp
from web.routes.main import bp as main_bp
from web.routes.webhooks import bp as webhooks_bp
# Register authentication blueprint
app.register_blueprint(auth_bp, url_prefix='/auth')
@@ -340,10 +345,14 @@ def register_blueprints(app: Flask) -> None:
# Register main web routes blueprint
app.register_blueprint(main_bp, url_prefix='/')
# Register webhooks web routes blueprint
app.register_blueprint(webhooks_bp, url_prefix='/webhooks')
# Register API blueprints
app.register_blueprint(scans_bp, url_prefix='/api/scans')
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
app.register_blueprint(webhooks_api_bp, url_prefix='/api/webhooks')
app.register_blueprint(settings_bp, url_prefix='/api/settings')
app.register_blueprint(stats_bp, url_prefix='/api/stats')
app.register_blueprint(configs_bp, url_prefix='/api/configs')
@@ -487,6 +496,35 @@ def register_error_handlers(app: Flask) -> None:
return render_template('errors/500.html', error=error), 500
def register_context_processors(app: Flask) -> None:
"""
Register template context processors.
Makes common variables available to all templates without having to
pass them explicitly in every render_template call.
Args:
app: Flask application instance
"""
@app.context_processor
def inject_app_settings():
"""
Inject application metadata into all templates.
Returns:
Dictionary of variables to add to template context
"""
from web.config import APP_NAME, APP_VERSION, REPO_URL
return {
'app_name': APP_NAME,
'app_version': APP_VERSION,
'repo_url': REPO_URL
}
app.logger.info("Context processors registered")
def register_request_handlers(app: Flask) -> None:
"""
Register request and response handlers.

13
app/web/config.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Application configuration and metadata.
Contains version information and other application-level constants
that are managed by developers, not stored in the database.
"""
# Application metadata
APP_NAME = 'SneakyScanner'
APP_VERSION = '1.0.0-phase5'
# Repository URL
REPO_URL = 'https://git.sneakygeek.net/sneakygeek/SneakyScan'

View File

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

View File

@@ -0,0 +1,59 @@
"""
Background webhook delivery job execution.
This module handles the execution of webhook deliveries in background threads,
updating delivery logs and handling errors.
"""
import logging
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.services.webhook_service import WebhookService
logger = logging.getLogger(__name__)
def execute_webhook_delivery(webhook_id: int, alert_id: int, db_url: str):
"""
Execute a webhook delivery in the background.
This function is designed to run in a background thread via APScheduler.
It creates its own database session to avoid conflicts with the main
application thread.
Args:
webhook_id: ID of the webhook to deliver
alert_id: ID of the alert to send
db_url: Database connection URL
Workflow:
1. Create new database session for this thread
2. Call WebhookService to deliver webhook
3. WebhookService handles retry logic and logging
4. Close session
"""
logger.info(f"Starting background webhook delivery: webhook_id={webhook_id}, alert_id={alert_id}")
# Create new database session for this thread
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Create webhook service and deliver
webhook_service = WebhookService(session)
success = webhook_service.deliver_webhook(webhook_id, alert_id)
if success:
logger.info(f"Webhook {webhook_id} delivered successfully for alert {alert_id}")
else:
logger.warning(f"Webhook {webhook_id} delivery failed for alert {alert_id}")
except Exception as e:
logger.error(f"Error during webhook delivery: {e}", exc_info=True)
finally:
session.close()
engine.dispose()
logger.info(f"Webhook delivery job completed: webhook_id={webhook_id}, alert_id={alert_id}")

View File

@@ -284,17 +284,24 @@ class Alert(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
alert_type = Column(String(50), nullable=False, comment="new_port, cert_expiry, service_change, ping_failed")
rule_id = Column(Integer, ForeignKey('alert_rules.id'), nullable=True, index=True, comment="Associated alert rule")
alert_type = Column(String(50), nullable=False, comment="unexpected_port, drift_detection, cert_expiry, service_change, ping_failed")
severity = Column(String(20), nullable=False, comment="info, warning, critical")
message = Column(Text, nullable=False, comment="Human-readable alert message")
ip_address = Column(String(45), nullable=True, comment="Related IP (optional)")
port = Column(Integer, nullable=True, comment="Related port (optional)")
email_sent = Column(Boolean, nullable=False, default=False, comment="Was email notification sent?")
email_sent_at = Column(DateTime, nullable=True, comment="Email send timestamp")
webhook_sent = Column(Boolean, nullable=False, default=False, comment="Was webhook sent?")
webhook_sent_at = Column(DateTime, nullable=True, comment="Webhook send timestamp")
acknowledged = Column(Boolean, nullable=False, default=False, index=True, comment="Was alert acknowledged?")
acknowledged_at = Column(DateTime, nullable=True, comment="Acknowledgment timestamp")
acknowledged_by = Column(String(255), nullable=True, comment="User who acknowledged")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Alert creation time")
# Relationships
scan = relationship('Scan', back_populates='alerts')
rule = relationship('AlertRule', back_populates='alerts')
# Index for alert queries by type and severity
__table_args__ = (
@@ -315,14 +322,82 @@ 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")
template = Column(Text, nullable=True, comment="Jinja2 template for webhook payload")
template_format = Column(String(20), nullable=True, default='json', comment="Template output format: json, text")
content_type_override = Column(String(100), nullable=True, comment="Optional custom Content-Type header")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Creation time")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Last update time")
# Relationships
delivery_logs = relationship("WebhookDeliveryLog", back_populates="webhook", cascade="all, delete-orphan")
def __repr__(self):
return f"<Webhook(id={self.id}, name='{self.name}', enabled={self.enabled})>"
class WebhookDeliveryLog(Base):
"""
Webhook delivery tracking.
Logs all webhook delivery attempts for auditing and debugging purposes.
"""
__tablename__ = 'webhook_delivery_log'
id = Column(Integer, primary_key=True, autoincrement=True)
webhook_id = Column(Integer, ForeignKey('webhooks.id'), nullable=False, index=True, comment="Associated webhook")
alert_id = Column(Integer, ForeignKey('alerts.id'), nullable=False, index=True, comment="Associated alert")
status = Column(String(20), nullable=True, index=True, comment="Delivery status: success, failed, retrying")
response_code = Column(Integer, nullable=True, comment="HTTP response code")
response_body = Column(Text, nullable=True, comment="Response body from webhook")
error_message = Column(Text, nullable=True, comment="Error message if failed")
attempt_number = Column(Integer, nullable=True, comment="Which attempt this was")
delivered_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Delivery timestamp")
# Relationships
webhook = relationship("Webhook", back_populates="delivery_logs")
alert = relationship("Alert")
def __repr__(self):
return f"<WebhookDeliveryLog(id={self.id}, webhook_id={self.webhook_id}, status='{self.status}')>"
# ============================================================================

View File

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

View File

@@ -0,0 +1,83 @@
"""
Webhook web routes for SneakyScanner.
Provides UI pages for managing webhooks and viewing delivery logs.
"""
import logging
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from web.auth.decorators import login_required
from web.models import Webhook
from web.services.webhook_service import WebhookService
logger = logging.getLogger(__name__)
bp = Blueprint('webhooks', __name__)
@bp.route('')
@login_required
def list_webhooks():
"""
Webhooks list page - shows all configured webhooks.
Returns:
Rendered webhooks list template
"""
return render_template('webhooks/list.html')
@bp.route('/new')
@login_required
def new_webhook():
"""
New webhook form page.
Returns:
Rendered webhook form template
"""
return render_template('webhooks/form.html', webhook=None, mode='create')
@bp.route('/<int:webhook_id>/edit')
@login_required
def edit_webhook(webhook_id):
"""
Edit webhook form page.
Args:
webhook_id: Webhook ID to edit
Returns:
Rendered webhook form template or 404
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
flash('Webhook not found', 'error')
return redirect(url_for('webhooks.list_webhooks'))
return render_template('webhooks/form.html', webhook=webhook, mode='edit')
@bp.route('/<int:webhook_id>/logs')
@login_required
def webhook_logs(webhook_id):
"""
Webhook delivery logs page.
Args:
webhook_id: Webhook ID
Returns:
Rendered webhook logs template or 404
"""
webhook = current_app.db_session.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
flash('Webhook not found', 'error')
return redirect(url_for('webhooks.list_webhooks'))
return render_template('webhooks/logs.html', webhook=webhook)

View File

@@ -0,0 +1,521 @@
"""
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(ScanServiceModel, ScanCertificate.service_id == ScanServiceModel.id)
.join(ScanPort, ScanServiceModel.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(ScanCertificate, ScanTLSVersion.certificate_id == ScanCertificate.id)
.join(ScanServiceModel, ScanCertificate.service_id == ScanServiceModel.id)
.join(ScanPort, ScanServiceModel.port_id == ScanPort.id)
.join(ScanIP, ScanPort.ip_id == ScanIP.id)
.filter(ScanPort.scan_id == scan.id)
.all()
)
# Group TLS versions by port/IP to create one alert per host
tls_by_host = {}
for tls, port, ip in tls_versions:
# Only alert on weak TLS versions that are supported
if tls.supported and tls.tls_version in ['TLS 1.0', 'TLS 1.1']:
key = (ip.ip_address, port.port)
if key not in tls_by_host:
tls_by_host[key] = {'ip': ip.ip_address, 'port': port.port, 'versions': []}
tls_by_host[key]['versions'].append(tls.tls_version)
# Create alerts for hosts with weak TLS
for host_key, host_data in tls_by_host.items():
severity = rule.severity or 'warning'
alerts_to_create.append({
'alert_type': 'weak_tls',
'severity': severity,
'message': f"Weak TLS versions supported on {host_data['ip']}:{host_data['port']}: {', '.join(host_data['versions'])}",
'ip_address': host_data['ip'],
'port': host_data['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 - queue for delivery
if rule.webhook_enabled:
try:
from flask import current_app
from .webhook_service import WebhookService
webhook_service = WebhookService(self.db)
# Get matching webhooks for this alert
matching_webhooks = webhook_service.get_matching_webhooks(alert)
if matching_webhooks:
# Get scheduler from app context
scheduler = getattr(current_app, 'scheduler', None)
# Queue delivery for each matching webhook
for webhook in matching_webhooks:
webhook_service.queue_webhook_delivery(
webhook.id,
alert.id,
scheduler_service=scheduler
)
logger.info(f"Queued webhook {webhook.id} ({webhook.name}) for alert {alert.id}")
else:
logger.debug(f"No matching webhooks found for alert {alert.id}")
except Exception as e:
logger.error(f"Failed to queue webhook notifications for alert {alert.id}: {e}", exc_info=True)
# Don't fail alert creation if webhook queueing fails
def acknowledge_alert(self, alert_id: int, acknowledged_by: str = "system") -> bool:
"""
Acknowledge an alert.
Args:
alert_id: ID of the alert to acknowledge
acknowledged_by: Username or system identifier
Returns:
True if successful, False otherwise
"""
try:
alert = self.db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
logger.error(f"Alert {alert_id} not found")
return False
alert.acknowledged = True
alert.acknowledged_at = datetime.now(timezone.utc)
alert.acknowledged_by = acknowledged_by
self.db.commit()
logger.info(f"Alert {alert_id} acknowledged by {acknowledged_by}")
return True
except Exception as e:
logger.error(f"Failed to acknowledge alert {alert_id}: {str(e)}")
self.db.rollback()
return False
def get_alerts_for_scan(self, scan_id: int) -> List[Alert]:
"""
Get all alerts for a specific scan.
Args:
scan_id: ID of the scan
Returns:
List of Alert objects
"""
return (
self.db.query(Alert)
.filter(Alert.scan_id == scan_id)
.order_by(Alert.severity.desc(), Alert.created_at.desc())
.all()
)

View File

@@ -0,0 +1,294 @@
"""
Webhook Template Service
Provides Jinja2 template rendering for webhook payloads with a sandboxed
environment and comprehensive context building from scan/alert/rule data.
"""
from jinja2 import Environment, BaseLoader, TemplateError, meta
from jinja2.sandbox import SandboxedEnvironment
import json
from typing import Dict, Any, Optional, Tuple
from datetime import datetime
class TemplateService:
"""
Service for rendering webhook templates safely using Jinja2.
Features:
- Sandboxed Jinja2 environment to prevent code execution
- Rich context with alert, scan, rule, service, cert data
- Support for both JSON and text output formats
- Template validation and error handling
"""
def __init__(self):
"""Initialize the sandboxed Jinja2 environment."""
self.env = SandboxedEnvironment(
loader=BaseLoader(),
autoescape=False, # We control the output format
trim_blocks=True,
lstrip_blocks=True
)
# Add custom filters
self.env.filters['isoformat'] = self._isoformat_filter
def _isoformat_filter(self, value):
"""Custom filter to convert datetime to ISO format."""
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def build_context(
self,
alert,
scan,
rule,
app_name: str = "SneakyScanner",
app_version: str = "1.0.0",
app_url: str = "https://github.com/sneakygeek/SneakyScan"
) -> Dict[str, Any]:
"""
Build the template context from alert, scan, and rule objects.
Args:
alert: Alert model instance
scan: Scan model instance
rule: AlertRule model instance
app_name: Application name
app_version: Application version
app_url: Application repository URL
Returns:
Dictionary with all available template variables
"""
context = {
"alert": {
"id": alert.id,
"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,
"acknowledged_by": alert.acknowledged_by,
"created_at": alert.created_at,
"email_sent": alert.email_sent,
"email_sent_at": alert.email_sent_at,
"webhook_sent": alert.webhook_sent,
"webhook_sent_at": alert.webhook_sent_at,
},
"scan": {
"id": scan.id,
"title": scan.title,
"timestamp": scan.timestamp,
"duration": scan.duration,
"status": scan.status,
"config_file": scan.config_file,
"triggered_by": scan.triggered_by,
"started_at": scan.started_at,
"completed_at": scan.completed_at,
"error_message": scan.error_message,
},
"rule": {
"id": rule.id,
"name": rule.name,
"type": rule.rule_type,
"threshold": rule.threshold,
"severity": rule.severity,
"enabled": rule.enabled,
},
"app": {
"name": app_name,
"version": app_version,
"url": app_url,
},
"timestamp": datetime.utcnow(),
}
# Add service information if available (for service-related alerts)
# This would require additional context from the caller
# For now, we'll add placeholder support
context["service"] = None
context["cert"] = None
context["tls"] = None
return context
def render(
self,
template_string: str,
context: Dict[str, Any],
template_format: str = 'json'
) -> Tuple[str, Optional[str]]:
"""
Render a template with the given context.
Args:
template_string: The Jinja2 template string
context: Template context dictionary
template_format: Output format ('json' or 'text')
Returns:
Tuple of (rendered_output, error_message)
- If successful: (rendered_string, None)
- If failed: (None, error_message)
"""
try:
template = self.env.from_string(template_string)
rendered = template.render(context)
# For JSON format, validate that the output is valid JSON
if template_format == 'json':
try:
# Parse to validate JSON structure
json.loads(rendered)
except json.JSONDecodeError as e:
return None, f"Template rendered invalid JSON: {str(e)}"
return rendered, None
except TemplateError as e:
return None, f"Template rendering error: {str(e)}"
except Exception as e:
return None, f"Unexpected error rendering template: {str(e)}"
def validate_template(
self,
template_string: str,
template_format: str = 'json'
) -> Tuple[bool, Optional[str]]:
"""
Validate a template without rendering it.
Args:
template_string: The Jinja2 template string to validate
template_format: Expected output format ('json' or 'text')
Returns:
Tuple of (is_valid, error_message)
- If valid: (True, None)
- If invalid: (False, error_message)
"""
try:
# Parse the template to check syntax
self.env.parse(template_string)
# For JSON templates, check if it looks like valid JSON structure
# (this is a basic check - full validation happens during render)
if template_format == 'json':
# Just check for basic JSON structure markers
stripped = template_string.strip()
if not (stripped.startswith('{') or stripped.startswith('[')):
return False, "JSON template must start with { or ["
return True, None
except TemplateError as e:
return False, f"Template syntax error: {str(e)}"
except Exception as e:
return False, f"Template validation error: {str(e)}"
def get_template_variables(self, template_string: str) -> set:
"""
Extract all variables used in a template.
Args:
template_string: The Jinja2 template string
Returns:
Set of variable names used in the template
"""
try:
ast = self.env.parse(template_string)
return meta.find_undeclared_variables(ast)
except Exception:
return set()
def render_test_payload(
self,
template_string: str,
template_format: str = 'json'
) -> Tuple[str, Optional[str]]:
"""
Render a template with sample/test data for preview purposes.
Args:
template_string: The Jinja2 template string
template_format: Output format ('json' or 'text')
Returns:
Tuple of (rendered_output, error_message)
"""
# Create sample context data
sample_context = {
"alert": {
"id": 123,
"type": "unexpected_port",
"severity": "warning",
"message": "Unexpected port 8080 found open on 192.168.1.100",
"ip_address": "192.168.1.100",
"port": 8080,
"acknowledged": False,
"acknowledged_at": None,
"acknowledged_by": None,
"created_at": datetime.utcnow(),
"email_sent": False,
"email_sent_at": None,
"webhook_sent": False,
"webhook_sent_at": None,
},
"scan": {
"id": 456,
"title": "Production Infrastructure Scan",
"timestamp": datetime.utcnow(),
"duration": 125.5,
"status": "completed",
"config_file": "production-scan.yaml",
"triggered_by": "schedule",
"started_at": datetime.utcnow(),
"completed_at": datetime.utcnow(),
"error_message": None,
},
"rule": {
"id": 789,
"name": "Unexpected Port Detection",
"type": "unexpected_port",
"threshold": None,
"severity": "warning",
"enabled": True,
},
"service": {
"name": "http",
"product": "nginx",
"version": "1.20.0",
},
"cert": {
"subject": "CN=example.com",
"issuer": "CN=Let's Encrypt Authority X3",
"days_until_expiry": 15,
},
"app": {
"name": "SneakyScanner",
"version": "1.0.0-phase5",
"url": "https://github.com/sneakygeek/SneakyScan",
},
"timestamp": datetime.utcnow(),
}
return self.render(template_string, sample_context, template_format)
# Singleton instance
_template_service = None
def get_template_service() -> TemplateService:
"""Get the singleton TemplateService instance."""
global _template_service
if _template_service is None:
_template_service = TemplateService()
return _template_service

View File

@@ -0,0 +1,566 @@
"""
Webhook Service Module
Handles webhook delivery for alert notifications with retry logic,
authentication support, and comprehensive logging.
"""
import json
import logging
import time
from datetime import datetime, timezone
from typing import List, Dict, Optional, Any, Tuple
from sqlalchemy.orm import Session
import requests
from requests.auth import HTTPBasicAuth
from cryptography.fernet import Fernet
import os
from ..models import Webhook, WebhookDeliveryLog, Alert, AlertRule, Scan
from .template_service import get_template_service
from ..config import APP_NAME, APP_VERSION, REPO_URL
logger = logging.getLogger(__name__)
class WebhookService:
"""
Service for webhook delivery and management.
Handles queuing webhook deliveries, executing HTTP requests with
authentication, retry logic, and logging delivery attempts.
"""
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
"""
Initialize webhook service.
Args:
db_session: SQLAlchemy database session
encryption_key: Fernet encryption key for auth_token encryption
"""
self.db = db_session
self._encryption_key = encryption_key or self._get_encryption_key()
self._cipher = Fernet(self._encryption_key) if self._encryption_key else None
def _get_encryption_key(self) -> Optional[bytes]:
"""
Get encryption key from environment or database.
Returns:
Fernet encryption key or None if not available
"""
# Try environment variable first
key_str = os.environ.get('SNEAKYSCANNER_ENCRYPTION_KEY')
if key_str:
return key_str.encode()
# Try to get from settings (would need to query Setting table)
# For now, generate a temporary key if none exists
try:
return Fernet.generate_key()
except Exception as e:
logger.warning(f"Could not generate encryption key: {e}")
return None
def _encrypt_value(self, value: str) -> str:
"""Encrypt a string value."""
if not self._cipher:
return value # Return plain text if encryption not available
return self._cipher.encrypt(value.encode()).decode()
def _decrypt_value(self, encrypted_value: str) -> str:
"""Decrypt an encrypted string value."""
if not self._cipher:
return encrypted_value # Return as-is if encryption not available
try:
return self._cipher.decrypt(encrypted_value.encode()).decode()
except Exception as e:
logger.error(f"Failed to decrypt value: {e}")
return encrypted_value
def get_matching_webhooks(self, alert: Alert) -> List[Webhook]:
"""
Get all enabled webhooks that match an alert's type and severity.
Args:
alert: Alert object to match against
Returns:
List of matching Webhook objects
"""
# Get all enabled webhooks
webhooks = self.db.query(Webhook).filter(Webhook.enabled == True).all()
matching_webhooks = []
for webhook in webhooks:
# Check if webhook matches alert type filter
if webhook.alert_types:
try:
alert_types = json.loads(webhook.alert_types)
if alert.alert_type not in alert_types:
continue # Skip if alert type doesn't match
except json.JSONDecodeError:
logger.warning(f"Invalid alert_types JSON for webhook {webhook.id}")
continue
# Check if webhook matches severity filter
if webhook.severity_filter:
try:
severity_filter = json.loads(webhook.severity_filter)
if alert.severity not in severity_filter:
continue # Skip if severity doesn't match
except json.JSONDecodeError:
logger.warning(f"Invalid severity_filter JSON for webhook {webhook.id}")
continue
matching_webhooks.append(webhook)
logger.info(f"Found {len(matching_webhooks)} matching webhooks for alert {alert.id}")
return matching_webhooks
def queue_webhook_delivery(self, webhook_id: int, alert_id: int, scheduler_service=None) -> bool:
"""
Queue a webhook delivery for async execution via APScheduler.
Args:
webhook_id: ID of webhook to deliver
alert_id: ID of alert to send
scheduler_service: SchedulerService instance (if None, deliver synchronously)
Returns:
True if queued successfully, False otherwise
"""
if scheduler_service and scheduler_service.scheduler:
try:
# Import here to avoid circular dependency
from web.jobs.webhook_job import execute_webhook_delivery
# Schedule immediate execution
scheduler_service.scheduler.add_job(
execute_webhook_delivery,
args=[webhook_id, alert_id, scheduler_service.db_url],
id=f"webhook_{webhook_id}_{alert_id}_{int(time.time())}",
replace_existing=False
)
logger.info(f"Queued webhook {webhook_id} for alert {alert_id}")
return True
except Exception as e:
logger.error(f"Failed to queue webhook delivery: {e}")
# Fall back to synchronous delivery
return self.deliver_webhook(webhook_id, alert_id)
else:
# No scheduler available, deliver synchronously
logger.info(f"No scheduler available, delivering webhook {webhook_id} synchronously")
return self.deliver_webhook(webhook_id, alert_id)
def deliver_webhook(self, webhook_id: int, alert_id: int, attempt_number: int = 1) -> bool:
"""
Deliver a webhook with retry logic.
Args:
webhook_id: ID of webhook to deliver
alert_id: ID of alert to send
attempt_number: Current attempt number (for retries)
Returns:
True if delivered successfully, False otherwise
"""
# Get webhook and alert
webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
logger.error(f"Webhook {webhook_id} not found")
return False
alert = self.db.query(Alert).filter(Alert.id == alert_id).first()
if not alert:
logger.error(f"Alert {alert_id} not found")
return False
logger.info(f"Delivering webhook {webhook_id} for alert {alert_id} (attempt {attempt_number}/{webhook.retry_count})")
# Build payload with template support
payload, content_type = self._build_payload(webhook, alert)
# Prepare headers
headers = {'Content-Type': content_type}
# Add custom headers if provided
if webhook.custom_headers:
try:
custom_headers = json.loads(webhook.custom_headers)
headers.update(custom_headers)
except json.JSONDecodeError:
logger.warning(f"Invalid custom_headers JSON for webhook {webhook_id}")
# Prepare authentication
auth = None
if webhook.auth_type == 'bearer' and webhook.auth_token:
decrypted_token = self._decrypt_value(webhook.auth_token)
headers['Authorization'] = f'Bearer {decrypted_token}'
elif webhook.auth_type == 'basic' and webhook.auth_token:
# Expecting format: "username:password"
decrypted_token = self._decrypt_value(webhook.auth_token)
if ':' in decrypted_token:
username, password = decrypted_token.split(':', 1)
auth = HTTPBasicAuth(username, password)
# Execute HTTP request
try:
timeout = webhook.timeout or 10
# Use appropriate parameter based on payload type
if isinstance(payload, dict):
# JSON payload
response = requests.post(
webhook.url,
json=payload,
headers=headers,
auth=auth,
timeout=timeout
)
else:
# Text payload
response = requests.post(
webhook.url,
data=payload,
headers=headers,
auth=auth,
timeout=timeout
)
# Log delivery attempt
log_entry = WebhookDeliveryLog(
webhook_id=webhook_id,
alert_id=alert_id,
status='success' if response.status_code < 400 else 'failed',
response_code=response.status_code,
response_body=response.text[:1000], # Limit to 1000 chars
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
attempt_number=attempt_number,
delivered_at=datetime.now(timezone.utc)
)
self.db.add(log_entry)
# Update alert webhook status if successful
if response.status_code < 400:
alert.webhook_sent = True
alert.webhook_sent_at = datetime.now(timezone.utc)
logger.info(f"Webhook {webhook_id} delivered successfully (HTTP {response.status_code})")
self.db.commit()
return True
else:
# Failed but got a response
logger.warning(f"Webhook {webhook_id} failed with HTTP {response.status_code}")
self.db.commit()
# Retry if attempts remaining
if attempt_number < webhook.retry_count:
delay = self._calculate_retry_delay(attempt_number)
logger.info(f"Retrying webhook {webhook_id} in {delay} seconds")
time.sleep(delay)
return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1)
return False
except requests.exceptions.Timeout:
error_msg = f"Request timeout after {timeout} seconds"
logger.error(f"Webhook {webhook_id} timeout: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
except requests.exceptions.ConnectionError as e:
error_msg = f"Connection error: {str(e)}"
logger.error(f"Webhook {webhook_id} connection error: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
except requests.exceptions.RequestException as e:
error_msg = f"Request error: {str(e)}"
logger.error(f"Webhook {webhook_id} request error: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
logger.error(f"Webhook {webhook_id} unexpected error: {error_msg}")
self._log_delivery_failure(webhook_id, alert_id, error_msg, attempt_number)
# Retry if attempts remaining
if attempt_number < webhook.retry_count:
delay = self._calculate_retry_delay(attempt_number)
logger.info(f"Retrying webhook {webhook_id} in {delay} seconds")
time.sleep(delay)
return self.deliver_webhook(webhook_id, alert_id, attempt_number + 1)
return False
def _log_delivery_failure(self, webhook_id: int, alert_id: int, error_message: str, attempt_number: int):
"""Log a failed delivery attempt."""
log_entry = WebhookDeliveryLog(
webhook_id=webhook_id,
alert_id=alert_id,
status='failed',
response_code=None,
response_body=None,
error_message=error_message[:500], # Limit error message length
attempt_number=attempt_number,
delivered_at=datetime.now(timezone.utc)
)
self.db.add(log_entry)
self.db.commit()
def _calculate_retry_delay(self, attempt_number: int) -> int:
"""
Calculate exponential backoff delay for retries.
Args:
attempt_number: Current attempt number
Returns:
Delay in seconds
"""
# Exponential backoff: 2^attempt seconds (2, 4, 8, 16...)
return min(2 ** attempt_number, 60) # Cap at 60 seconds
def _build_payload(self, webhook: Webhook, alert: Alert) -> Tuple[Any, str]:
"""
Build payload for webhook delivery using template if configured.
Args:
webhook: Webhook object with optional template configuration
alert: Alert object
Returns:
Tuple of (payload, content_type):
- payload: Rendered payload (dict for JSON, string for text)
- content_type: Content-Type header value
"""
# Get related scan
scan = self.db.query(Scan).filter(Scan.id == alert.scan_id).first()
# Get related rule
rule = self.db.query(AlertRule).filter(AlertRule.id == alert.rule_id).first()
# If webhook has a custom template, use it
if webhook.template:
template_service = get_template_service()
context = template_service.build_context(
alert=alert,
scan=scan,
rule=rule,
app_name=APP_NAME,
app_version=APP_VERSION,
app_url=REPO_URL
)
rendered, error = template_service.render(
webhook.template,
context,
webhook.template_format or 'json'
)
if error:
logger.error(f"Template rendering error for webhook {webhook.id}: {error}")
# Fall back to default payload
return self._build_default_payload(alert, scan, rule), 'application/json'
# Determine content type
if webhook.content_type_override:
content_type = webhook.content_type_override
elif webhook.template_format == 'text':
content_type = 'text/plain'
else:
content_type = 'application/json'
# For JSON format, parse the rendered string back to a dict
# For text format, return as string
if webhook.template_format == 'json':
try:
payload = json.loads(rendered)
except json.JSONDecodeError:
logger.error(f"Failed to parse rendered JSON template for webhook {webhook.id}")
return self._build_default_payload(alert, scan, rule), 'application/json'
else:
payload = rendered
return payload, content_type
# No template - use default payload
return self._build_default_payload(alert, scan, rule), 'application/json'
def _build_default_payload(self, alert: Alert, scan: Optional[Scan], rule: Optional[AlertRule]) -> Dict[str, Any]:
"""
Build default JSON payload for webhook delivery.
Args:
alert: Alert object
scan: Scan object (optional)
rule: AlertRule object (optional)
Returns:
Dict containing alert details in generic JSON format
"""
payload = {
"event": "alert.created",
"alert": {
"id": alert.id,
"type": alert.alert_type,
"severity": alert.severity,
"message": alert.message,
"ip_address": alert.ip_address,
"port": alert.port,
"acknowledged": alert.acknowledged,
"created_at": alert.created_at.isoformat() if alert.created_at else None
},
"scan": {
"id": scan.id if scan else None,
"title": scan.title if scan else None,
"timestamp": scan.timestamp.isoformat() if scan and scan.timestamp else None,
"status": scan.status if scan else None
},
"rule": {
"id": rule.id if rule else None,
"name": rule.name if rule else None,
"type": rule.rule_type if rule else None,
"threshold": rule.threshold if rule else None
}
}
return payload
def test_webhook(self, webhook_id: int) -> Dict[str, Any]:
"""
Send a test payload to a webhook.
Args:
webhook_id: ID of webhook to test
Returns:
Dict with test result details
"""
webhook = self.db.query(Webhook).filter(Webhook.id == webhook_id).first()
if not webhook:
return {
'success': False,
'message': 'Webhook not found',
'status_code': None
}
# Build test payload - use template if configured
if webhook.template:
template_service = get_template_service()
rendered, error = template_service.render_test_payload(
webhook.template,
webhook.template_format or 'json'
)
if error:
return {
'success': False,
'message': f'Template error: {error}',
'status_code': None
}
# Determine content type
if webhook.content_type_override:
content_type = webhook.content_type_override
elif webhook.template_format == 'text':
content_type = 'text/plain'
else:
content_type = 'application/json'
# Parse JSON template
if webhook.template_format == 'json':
try:
payload = json.loads(rendered)
except json.JSONDecodeError:
return {
'success': False,
'message': 'Template rendered invalid JSON',
'status_code': None
}
else:
payload = rendered
else:
# Default test payload
payload = {
"event": "webhook.test",
"message": "This is a test webhook from SneakyScanner",
"timestamp": datetime.now(timezone.utc).isoformat(),
"webhook": {
"id": webhook.id,
"name": webhook.name
}
}
content_type = 'application/json'
# Prepare headers
headers = {'Content-Type': content_type}
if webhook.custom_headers:
try:
custom_headers = json.loads(webhook.custom_headers)
headers.update(custom_headers)
except json.JSONDecodeError:
pass
# Prepare authentication
auth = None
if webhook.auth_type == 'bearer' and webhook.auth_token:
decrypted_token = self._decrypt_value(webhook.auth_token)
headers['Authorization'] = f'Bearer {decrypted_token}'
elif webhook.auth_type == 'basic' and webhook.auth_token:
decrypted_token = self._decrypt_value(webhook.auth_token)
if ':' in decrypted_token:
username, password = decrypted_token.split(':', 1)
auth = HTTPBasicAuth(username, password)
# Execute test request
try:
timeout = webhook.timeout or 10
# Use appropriate parameter based on payload type
if isinstance(payload, dict):
# JSON payload
response = requests.post(
webhook.url,
json=payload,
headers=headers,
auth=auth,
timeout=timeout
)
else:
# Text payload
response = requests.post(
webhook.url,
data=payload,
headers=headers,
auth=auth,
timeout=timeout
)
return {
'success': response.status_code < 400,
'message': f'HTTP {response.status_code}',
'status_code': response.status_code,
'response_body': response.text[:500]
}
except requests.exceptions.Timeout:
return {
'success': False,
'message': f'Request timeout after {timeout} seconds',
'status_code': None
}
except requests.exceptions.ConnectionError as e:
return {
'success': False,
'message': f'Connection error: {str(e)}',
'status_code': None
}
except Exception as e:
return {
'success': False,
'message': f'Error: {str(e)}',
'status_code': None
}

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SneakyScanner{% endblock %}</title>
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
@@ -34,7 +34,7 @@
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
SneakyScanner
{{ app_name }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
@@ -57,6 +57,18 @@
<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 or 'webhook' 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>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
</ul>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
@@ -85,7 +97,7 @@
<div class="footer">
<div class="container-fluid">
SneakyScanner v1.0 - Phase 3 In Progress
<a href="{{ repo_url }}" target="_blank">{{ app_name }}</a> - v{{ app_version }}
</div>
</div>

View File

@@ -0,0 +1,9 @@
{
"title": "{{ scan.title }} - {{ alert.type|title|replace('_', ' ') }}",
"message": "{{ alert.message }}{% if alert.ip_address %} on {{ alert.ip_address }}{% endif %}{% if alert.port %}:{{ alert.port }}{% endif %}",
"priority": {% if alert.severity == 'critical' %}5{% elif alert.severity == 'warning' %}3{% else %}1{% endif %},
"severity": "{{ alert.severity }}",
"scan_id": {{ scan.id }},
"alert_id": {{ alert.id }},
"timestamp": "{{ timestamp.isoformat() }}"
}

View File

@@ -0,0 +1,25 @@
{
"event": "alert.created",
"alert": {
"id": {{ alert.id }},
"type": "{{ alert.type }}",
"severity": "{{ alert.severity }}",
"message": "{{ alert.message }}",
{% if alert.ip_address %}"ip_address": "{{ alert.ip_address }}",{% endif %}
{% if alert.port %}"port": {{ alert.port }},{% endif %}
"acknowledged": {{ alert.acknowledged|lower }},
"created_at": "{{ alert.created_at.isoformat() }}"
},
"scan": {
"id": {{ scan.id }},
"title": "{{ scan.title }}",
"timestamp": "{{ scan.timestamp.isoformat() }}",
"status": "{{ scan.status }}"
},
"rule": {
"id": {{ rule.id }},
"name": "{{ rule.name }}",
"type": "{{ rule.type }}",
"threshold": {{ rule.threshold if rule.threshold else 'null' }}
}
}

View File

@@ -0,0 +1,41 @@
{
"username": "SneakyScanner",
"embeds": [
{
"title": "{{ alert.type|title|replace('_', ' ') }} Alert",
"description": "{{ alert.message }}",
"color": {% if alert.severity == 'critical' %}15158332{% elif alert.severity == 'warning' %}16776960{% else %}3447003{% endif %},
"fields": [
{
"name": "Severity",
"value": "{{ alert.severity|upper }}",
"inline": true
},
{
"name": "Scan",
"value": "{{ scan.title }}",
"inline": true
},
{
"name": "Rule",
"value": "{{ rule.name }}",
"inline": false
}{% if alert.ip_address %},
{
"name": "IP Address",
"value": "{{ alert.ip_address }}",
"inline": true
}{% endif %}{% if alert.port %},
{
"name": "Port",
"value": "{{ alert.port }}",
"inline": true
}{% endif %}
],
"footer": {
"text": "Alert ID: {{ alert.id }} | Scan ID: {{ scan.id }}"
},
"timestamp": "{{ timestamp.isoformat() }}"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"title": "{{ scan.title }}",
"message": "**{{ alert.severity|upper }}**: {{ alert.message }}\n\n**Scan:** {{ scan.title }}\n**Status:** {{ scan.status }}\n**Rule:** {{ rule.name }}{% if alert.ip_address %}\n**IP:** {{ alert.ip_address }}{% endif %}{% if alert.port %}\n**Port:** {{ alert.port }}{% endif %}",
"priority": {% if alert.severity == 'critical' %}8{% elif alert.severity == 'warning' %}5{% else %}2{% endif %},
"extras": {
"client::display": {
"contentType": "text/markdown"
},
"alert_id": {{ alert.id }},
"scan_id": {{ scan.id }},
"alert_type": "{{ alert.type }}"
}
}

View File

@@ -0,0 +1,10 @@
{{ alert.message }}
Scan: {{ scan.title }}
Rule: {{ rule.name }}
Severity: {{ alert.severity|upper }}{% if alert.ip_address %}
IP: {{ alert.ip_address }}{% endif %}{% if alert.port %}
Port: {{ alert.port }}{% endif %}
Scan Status: {{ scan.status }}
Alert ID: {{ alert.id }}

View File

@@ -0,0 +1,27 @@
SNEAKYSCANNER ALERT - {{ alert.severity|upper }}
Alert: {{ alert.message }}
Type: {{ alert.type|title|replace('_', ' ') }}
Severity: {{ alert.severity|upper }}
Scan Information:
Title: {{ scan.title }}
Status: {{ scan.status }}
Duration: {{ scan.duration }}s
Triggered By: {{ scan.triggered_by }}
Rule Information:
Name: {{ rule.name }}
Type: {{ rule.type }}
{% if rule.threshold %} Threshold: {{ rule.threshold }}
{% endif %}
{% if alert.ip_address %}IP Address: {{ alert.ip_address }}
{% endif %}{% if alert.port %}Port: {{ alert.port }}
{% endif %}
Alert ID: {{ alert.id }}
Scan ID: {{ scan.id }}
Timestamp: {{ timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}
---
Generated by {{ app.name }} v{{ app.version }}
{{ app.url }}

View File

@@ -0,0 +1,65 @@
[
{
"id": "default_json",
"name": "Default JSON (Current Format)",
"description": "Standard webhook payload format matching the current implementation",
"format": "json",
"content_type": "application/json",
"file": "default_json.j2",
"category": "general"
},
{
"id": "custom_json",
"name": "Custom JSON",
"description": "Flexible custom JSON format with configurable title, message, and priority fields",
"format": "json",
"content_type": "application/json",
"file": "custom_json.j2",
"category": "general"
},
{
"id": "gotify",
"name": "Gotify",
"description": "Optimized for Gotify push notification server with markdown support",
"format": "json",
"content_type": "application/json",
"file": "gotify.j2",
"category": "service"
},
{
"id": "ntfy",
"name": "Ntfy",
"description": "Simple text format for Ntfy pub-sub notification service",
"format": "text",
"content_type": "text/plain",
"file": "ntfy.j2",
"category": "service"
},
{
"id": "slack",
"name": "Slack",
"description": "Rich Block Kit format for Slack webhooks with visual formatting",
"format": "json",
"content_type": "application/json",
"file": "slack.j2",
"category": "service"
},
{
"id": "discord",
"name": "Discord",
"description": "Embedded message format for Discord webhooks with color-coded severity",
"format": "json",
"content_type": "application/json",
"file": "discord.j2",
"category": "service"
},
{
"id": "plain_text",
"name": "Plain Text",
"description": "Simple plain text format for logging or basic notification services",
"format": "text",
"content_type": "text/plain",
"file": "plain_text.j2",
"category": "general"
}
]

View File

@@ -0,0 +1,60 @@
{
"text": "{{ alert.severity|upper }}: {{ alert.message }}",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "🚨 {{ alert.severity|upper }} Alert: {{ alert.type|title|replace('_', ' ') }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Alert:*\n{{ alert.message }}"
},
{
"type": "mrkdwn",
"text": "*Severity:*\n{{ alert.severity|upper }}"
}
]
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Scan:*\n{{ scan.title }}"
},
{
"type": "mrkdwn",
"text": "*Rule:*\n{{ rule.name }}"
}
]
}{% if alert.ip_address or alert.port %},
{
"type": "section",
"fields": [{% if alert.ip_address %}
{
"type": "mrkdwn",
"text": "*IP Address:*\n{{ alert.ip_address }}"
}{% if alert.port %},{% endif %}{% endif %}{% if alert.port %}
{
"type": "mrkdwn",
"text": "*Port:*\n{{ alert.port }}"
}{% endif %}
]
}{% endif %},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "Scan ID: {{ scan.id }} | Alert ID: {{ alert.id }} | {{ timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') }}"
}
]
}
]
}

View File

@@ -0,0 +1,633 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Webhook - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 mb-4">
<h1 style="color: #60a5fa;">{{ 'Edit' if mode == 'edit' else 'Create' }} Webhook</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
<li class="breadcrumb-item active">{{ 'Edit' if mode == 'edit' else 'New' }}</li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form id="webhook-form">
<!-- Basic Information -->
<h5 class="card-title mb-3">Basic Information</h5>
<div class="mb-3">
<label for="name" class="form-label">Webhook Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required
placeholder="e.g., Slack Notifications">
<div class="form-text">A descriptive name for this webhook</div>
</div>
<div class="mb-3">
<label for="url" class="form-label">Webhook URL <span class="text-danger">*</span></label>
<input type="url" class="form-control" id="url" name="url" required
placeholder="https://hooks.example.com/webhook">
<div class="form-text">The endpoint where alerts will be sent</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="enabled" name="enabled" checked>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<div class="form-text">Disabled webhooks will not receive notifications</div>
</div>
<hr class="my-4">
<!-- Authentication -->
<h5 class="card-title mb-3">Authentication</h5>
<div class="mb-3">
<label for="auth_type" class="form-label">Authentication Type</label>
<select class="form-select" id="auth_type" name="auth_type">
<option value="none">None</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth (username:password)</option>
<option value="custom">Custom Headers</option>
</select>
</div>
<div class="mb-3" id="auth_token_field" style="display: none;">
<label for="auth_token" class="form-label">Authentication Token</label>
<input type="password" class="form-control" id="auth_token" name="auth_token"
placeholder="Enter token or username:password">
<div class="form-text" id="auth_token_help">Will be encrypted when stored</div>
</div>
<div class="mb-3" id="custom_headers_field" style="display: none;">
<label for="custom_headers" class="form-label">Custom Headers (JSON)</label>
<textarea class="form-control font-monospace" id="custom_headers" name="custom_headers" rows="4"
placeholder='{"X-API-Key": "your-key", "X-Custom-Header": "value"}'></textarea>
<div class="form-text">JSON object with custom HTTP headers</div>
</div>
<hr class="my-4">
<!-- Filters -->
<h5 class="card-title mb-3">Alert Filters</h5>
<div class="mb-3">
<label class="form-label">Alert Types</label>
<div class="form-text mb-2">Select which alert types trigger this webhook (leave all unchecked for all types)</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="unexpected_port" id="type_unexpected">
<label class="form-check-label" for="type_unexpected">Unexpected Port</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="drift_detection" id="type_drift">
<label class="form-check-label" for="type_drift">Drift Detection</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="cert_expiry" id="type_cert">
<label class="form-check-label" for="type_cert">Certificate Expiry</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="weak_tls" id="type_tls">
<label class="form-check-label" for="type_tls">Weak TLS</label>
</div>
<div class="form-check">
<input class="form-check-input alert-type-check" type="checkbox" value="ping_failed" id="type_ping">
<label class="form-check-label" for="type_ping">Ping Failed</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Severity Filter</label>
<div class="form-text mb-2">Select which severities trigger this webhook (leave all unchecked for all severities)</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="critical" id="severity_critical">
<label class="form-check-label" for="severity_critical">Critical</label>
</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="warning" id="severity_warning">
<label class="form-check-label" for="severity_warning">Warning</label>
</div>
<div class="form-check">
<input class="form-check-input severity-check" type="checkbox" value="info" id="severity_info">
<label class="form-check-label" for="severity_info">Info</label>
</div>
</div>
<hr class="my-4">
<!-- Webhook Template -->
<h5 class="card-title mb-3">Webhook Template</h5>
<div class="alert alert-info small">
<i class="bi bi-info-circle"></i>
Customize the webhook payload using Jinja2 templates. Leave empty to use the default JSON format.
</div>
<div class="mb-3">
<label for="preset_selector" class="form-label">Load Preset Template</label>
<select class="form-select" id="preset_selector">
<option value="">-- Select a preset --</option>
</select>
<div class="form-text">Choose from pre-built templates for popular services</div>
</div>
<div class="mb-3">
<label for="template_format" class="form-label">Template Format</label>
<select class="form-select" id="template_format" name="template_format">
<option value="json">JSON</option>
<option value="text">Plain Text</option>
</select>
<div class="form-text">Output format of the rendered template</div>
</div>
<div class="mb-3">
<label for="template" class="form-label">Template</label>
<textarea class="form-control font-monospace" id="template" name="template" rows="12"
placeholder="Leave empty for default format, or enter custom Jinja2 template..."></textarea>
<div class="form-text">
Available variables: <code>{{ "{{" }} alert.* {{ "}}" }}</code>, <code>{{ "{{" }} scan.* {{ "}}" }}</code>, <code>{{ "{{" }} rule.* {{ "}}" }}</code>
<a href="#" data-bs-toggle="modal" data-bs-target="#variablesModal">View all variables</a>
</div>
</div>
<div class="mb-3">
<label for="content_type_override" class="form-label">Custom Content-Type (optional)</label>
<input type="text" class="form-control font-monospace" id="content_type_override" name="content_type_override"
placeholder="e.g., application/json, text/plain, text/markdown">
<div class="form-text">Override the default Content-Type header (auto-detected from template format if not set)</div>
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary btn-sm" id="preview-template-btn">
<i class="bi bi-eye"></i> Preview Template
</button>
<button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="clear-template-btn">
<i class="bi bi-x-circle"></i> Clear Template
</button>
</div>
<hr class="my-4">
<!-- Advanced Settings -->
<h5 class="card-title mb-3">Advanced Settings</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label for="timeout" class="form-label">Timeout (seconds)</label>
<input type="number" class="form-control" id="timeout" name="timeout" min="1" max="60" value="10">
<div class="form-text">Maximum time to wait for response</div>
</div>
<div class="col-md-6 mb-3">
<label for="retry_count" class="form-label">Retry Count</label>
<input type="number" class="form-control" id="retry_count" name="retry_count" min="0" max="5" value="3">
<div class="form-text">Number of retry attempts on failure</div>
</div>
</div>
<hr class="my-4">
<!-- Submit Buttons -->
<div class="d-flex justify-content-between">
<a href="{{ url_for('webhooks.list_webhooks') }}" class="btn btn-secondary">Cancel</a>
<div>
<button type="button" class="btn btn-outline-primary me-2" id="test-btn">
<i class="bi bi-send"></i> Test Webhook
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Save Webhook
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Help Sidebar -->
<div class="col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-info-circle"></i> Help</h5>
<h6 class="mt-3">Payload Format</h6>
<p class="small text-muted">Default JSON payload format (can be customized with templates):</p>
<pre class="small bg-dark text-light p-2 rounded"><code>{
"event": "alert.created",
"alert": {
"id": 123,
"type": "cert_expiry",
"severity": "warning",
"message": "...",
"ip_address": "192.168.1.10",
"port": 443
},
"scan": {...},
"rule": {...}
}</code></pre>
<h6 class="mt-3">Custom Templates</h6>
<p class="small text-muted">Use Jinja2 templates to customize payloads for services like Slack, Discord, Gotify, or create your own format. Select a preset or write a custom template.</p>
<h6 class="mt-3">Authentication Types</h6>
<ul class="small">
<li><strong>None:</strong> No authentication</li>
<li><strong>Bearer:</strong> Add Authorization header with token</li>
<li><strong>Basic:</strong> Use username:password format</li>
<li><strong>Custom:</strong> Define custom HTTP headers</li>
</ul>
<h6 class="mt-3">Retry Logic</h6>
<p class="small text-muted">Failed webhooks are retried with exponential backoff (2^attempt seconds, max 60s).</p>
</div>
</div>
</div>
</div>
<!-- Template Variables Modal -->
<div class="modal fade" id="variablesModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Available Template Variables</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6>Alert Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} alert.id {{ "}}" }} - Alert ID</li>
<li>{{ "{{" }} alert.type {{ "}}" }} - Alert type (unexpected_port, cert_expiry, etc.)</li>
<li>{{ "{{" }} alert.severity {{ "}}" }} - Severity level (critical, warning, info)</li>
<li>{{ "{{" }} alert.message {{ "}}" }} - Human-readable alert message</li>
<li>{{ "{{" }} alert.ip_address {{ "}}" }} - IP address (if applicable)</li>
<li>{{ "{{" }} alert.port {{ "}}" }} - Port number (if applicable)</li>
<li>{{ "{{" }} alert.acknowledged {{ "}}" }} - Boolean: is acknowledged</li>
<li>{{ "{{" }} alert.created_at {{ "}}" }} - Alert creation timestamp</li>
</ul>
<h6 class="mt-3">Scan Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} scan.id {{ "}}" }} - Scan ID</li>
<li>{{ "{{" }} scan.title {{ "}}" }} - Scan title from config</li>
<li>{{ "{{" }} scan.timestamp {{ "}}" }} - Scan start time</li>
<li>{{ "{{" }} scan.duration {{ "}}" }} - Scan duration in seconds</li>
<li>{{ "{{" }} scan.status {{ "}}" }} - Scan status (running, completed, failed)</li>
<li>{{ "{{" }} scan.triggered_by {{ "}}" }} - How scan was triggered (manual, scheduled, api)</li>
</ul>
<h6 class="mt-3">Rule Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} rule.id {{ "}}" }} - Rule ID</li>
<li>{{ "{{" }} rule.name {{ "}}" }} - Rule name</li>
<li>{{ "{{" }} rule.type {{ "}}" }} - Rule type</li>
<li>{{ "{{" }} rule.threshold {{ "}}" }} - Rule threshold value</li>
<li>{{ "{{" }} rule.severity {{ "}}" }} - Rule severity</li>
</ul>
<h6 class="mt-3">App Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} app.name {{ "}}" }} - Application name</li>
<li>{{ "{{" }} app.version {{ "}}" }} - Application version</li>
<li>{{ "{{" }} app.url {{ "}}" }} - Repository URL</li>
</ul>
<h6 class="mt-3">Other Variables</h6>
<ul class="small font-monospace">
<li>{{ "{{" }} timestamp {{ "}}" }} - Current UTC timestamp</li>
</ul>
<h6 class="mt-3">Jinja2 Features</h6>
<p class="small">Templates support Jinja2 syntax including:</p>
<ul class="small">
<li>Conditionals: <code>{{ "{%" }} if alert.severity == 'critical' {{ "%}" }}...{{ "{%" }} endif {{ "%}" }}</code></li>
<li>Filters: <code>{{ "{{" }} alert.type|upper {{ "}}" }}</code>, <code>{{ "{{" }} alert.created_at.isoformat() {{ "}}" }}</code></li>
<li>Default values: <code>{{ "{{" }} alert.port|default('N/A') {{ "}}" }}</code></li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Template Preview Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Template Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="small text-muted">Preview using sample data:</p>
<pre class="bg-dark text-light p-3 rounded" id="preview-output" style="max-height: 500px; overflow-y: auto;"><code></code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const webhookId = {{ webhook.id if webhook else 'null' }};
const mode = '{{ mode }}';
// Load template presets on page load
async function loadPresets() {
try {
const response = await fetch('/api/webhooks/template-presets');
const data = await response.json();
if (data.status === 'success') {
const selector = document.getElementById('preset_selector');
data.presets.forEach(preset => {
const option = document.createElement('option');
option.value = JSON.stringify({
template: preset.template,
format: preset.format,
content_type: preset.content_type
});
option.textContent = `${preset.name} - ${preset.description}`;
selector.appendChild(option);
});
}
} catch (error) {
console.error('Failed to load presets:', error);
}
}
// Handle preset selection
document.getElementById('preset_selector').addEventListener('change', function() {
if (!this.value) return;
try {
const preset = JSON.parse(this.value);
document.getElementById('template').value = preset.template;
document.getElementById('template_format').value = preset.format;
document.getElementById('content_type_override').value = preset.content_type;
} catch (error) {
console.error('Failed to load preset:', error);
}
});
// Handle preview template button
document.getElementById('preview-template-btn').addEventListener('click', async function() {
const template = document.getElementById('template').value.trim();
if (!template) {
alert('Please enter a template first');
return;
}
const templateFormat = document.getElementById('template_format').value;
try {
const response = await fetch('/api/webhooks/preview-template', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template: template,
template_format: templateFormat
})
});
const result = await response.json();
if (result.status === 'success') {
// Display preview in modal
const output = document.querySelector('#preview-output code');
if (templateFormat === 'json') {
// Pretty print JSON
try {
const parsed = JSON.parse(result.rendered);
output.textContent = JSON.stringify(parsed, null, 2);
} catch (e) {
output.textContent = result.rendered;
}
} else {
output.textContent = result.rendered;
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
modal.show();
} else {
alert(`Preview failed: ${result.message}`);
}
} catch (error) {
console.error('Error previewing template:', error);
alert('Failed to preview template');
}
});
// Handle clear template button
document.getElementById('clear-template-btn').addEventListener('click', function() {
if (confirm('Clear template and reset to default format?')) {
document.getElementById('template').value = '';
document.getElementById('template_format').value = 'json';
document.getElementById('content_type_override').value = '';
document.getElementById('preset_selector').value = '';
}
});
// Load presets on page load
loadPresets();
// Show/hide auth fields based on type
document.getElementById('auth_type').addEventListener('change', function() {
const authType = this.value;
const tokenField = document.getElementById('auth_token_field');
const headersField = document.getElementById('custom_headers_field');
const tokenHelp = document.getElementById('auth_token_help');
tokenField.style.display = 'none';
headersField.style.display = 'none';
if (authType === 'bearer') {
tokenField.style.display = 'block';
document.getElementById('auth_token').placeholder = 'Enter bearer token';
tokenHelp.textContent = 'Bearer token for Authorization header (encrypted when stored)';
} else if (authType === 'basic') {
tokenField.style.display = 'block';
document.getElementById('auth_token').placeholder = 'username:password';
tokenHelp.textContent = 'Format: username:password (encrypted when stored)';
} else if (authType === 'custom') {
headersField.style.display = 'block';
}
});
// Load existing webhook data if editing
if (mode === 'edit' && webhookId) {
loadWebhookData(webhookId);
}
async function loadWebhookData(id) {
try {
const response = await fetch(`/api/webhooks/${id}`);
const data = await response.json();
const webhook = data.webhook;
// Populate form fields
document.getElementById('name').value = webhook.name || '';
document.getElementById('url').value = webhook.url || '';
document.getElementById('enabled').checked = webhook.enabled;
document.getElementById('auth_type').value = webhook.auth_type || 'none';
document.getElementById('timeout').value = webhook.timeout || 10;
document.getElementById('retry_count').value = webhook.retry_count || 3;
// Trigger auth type change to show relevant fields
document.getElementById('auth_type').dispatchEvent(new Event('change'));
// Don't populate auth_token (it's encrypted)
if (webhook.custom_headers) {
document.getElementById('custom_headers').value = JSON.stringify(webhook.custom_headers, null, 2);
}
// Check alert types
if (webhook.alert_types && webhook.alert_types.length > 0) {
webhook.alert_types.forEach(type => {
const checkbox = document.querySelector(`.alert-type-check[value="${type}"]`);
if (checkbox) checkbox.checked = true;
});
}
// Check severities
if (webhook.severity_filter && webhook.severity_filter.length > 0) {
webhook.severity_filter.forEach(sev => {
const checkbox = document.querySelector(`.severity-check[value="${sev}"]`);
if (checkbox) checkbox.checked = true;
});
}
// Load template fields
if (webhook.template) {
document.getElementById('template').value = webhook.template;
}
if (webhook.template_format) {
document.getElementById('template_format').value = webhook.template_format;
}
if (webhook.content_type_override) {
document.getElementById('content_type_override').value = webhook.content_type_override;
}
} catch (error) {
console.error('Error loading webhook:', error);
alert('Failed to load webhook data');
}
}
// Form submission
document.getElementById('webhook-form').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
name: document.getElementById('name').value,
url: document.getElementById('url').value,
enabled: document.getElementById('enabled').checked,
auth_type: document.getElementById('auth_type').value,
timeout: parseInt(document.getElementById('timeout').value),
retry_count: parseInt(document.getElementById('retry_count').value)
};
// Add auth token if provided
const authToken = document.getElementById('auth_token').value;
if (authToken) {
formData.auth_token = authToken;
}
// Add custom headers if provided
const customHeaders = document.getElementById('custom_headers').value;
if (customHeaders.trim()) {
try {
formData.custom_headers = JSON.parse(customHeaders);
} catch (e) {
alert('Invalid JSON in custom headers');
return;
}
}
// Collect selected alert types
const alertTypes = Array.from(document.querySelectorAll('.alert-type-check:checked'))
.map(cb => cb.value);
if (alertTypes.length > 0) {
formData.alert_types = alertTypes;
}
// Collect selected severities
const severities = Array.from(document.querySelectorAll('.severity-check:checked'))
.map(cb => cb.value);
if (severities.length > 0) {
formData.severity_filter = severities;
}
// Add template fields
const template = document.getElementById('template').value.trim();
if (template) {
formData.template = template;
formData.template_format = document.getElementById('template_format').value;
const contentTypeOverride = document.getElementById('content_type_override').value.trim();
if (contentTypeOverride) {
formData.content_type_override = contentTypeOverride;
}
}
try {
const url = mode === 'edit' ? `/api/webhooks/${webhookId}` : '/api/webhooks';
const method = mode === 'edit' ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.status === 'success') {
alert('Webhook saved successfully!');
window.location.href = '{{ url_for("webhooks.list_webhooks") }}';
} else {
alert(`Failed to save webhook: ${result.message}`);
}
} catch (error) {
console.error('Error saving webhook:', error);
alert('Failed to save webhook');
}
});
// Test webhook button
document.getElementById('test-btn').addEventListener('click', async function() {
if (mode !== 'edit' || !webhookId) {
alert('Please save the webhook first before testing');
return;
}
if (!confirm('Send a test payload to this webhook?')) return;
try {
const response = await fetch(`/api/webhooks/${webhookId}/test`, { method: 'POST' });
const result = await response.json();
if (result.status === 'success') {
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
} else {
alert(`Test failed:\n${result.message}`);
}
} catch (error) {
console.error('Error testing webhook:', error);
alert('Failed to test webhook');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block title %}Webhooks - 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;">Webhook Management</h1>
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Webhook
</a>
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Webhooks table -->
<div id="webhooks-container" style="display: none;">
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Alert Types</th>
<th>Severity Filter</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="webhooks-tbody">
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Webhooks pagination" id="pagination-container">
<ul class="pagination justify-content-center" id="pagination">
<!-- Populated via JavaScript -->
</ul>
</nav>
</div>
<!-- Empty state -->
<div id="empty-state" class="text-center my-5" style="display: none;">
<i class="bi bi-webhook" style="font-size: 4rem; color: #94a3b8;"></i>
<p class="text-muted mt-3">No webhooks configured yet.</p>
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Your First Webhook
</a>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
const perPage = 20;
async function loadWebhooks(page = 1) {
try {
const response = await fetch(`/api/webhooks?page=${page}&per_page=${perPage}`);
const data = await response.json();
if (data.webhooks && data.webhooks.length > 0) {
renderWebhooks(data.webhooks);
renderPagination(data.page, data.pages, data.total);
document.getElementById('webhooks-container').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
} else {
document.getElementById('webhooks-container').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
}
} catch (error) {
console.error('Error loading webhooks:', error);
alert('Failed to load webhooks');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function renderWebhooks(webhooks) {
const tbody = document.getElementById('webhooks-tbody');
tbody.innerHTML = '';
webhooks.forEach(webhook => {
const row = document.createElement('tr');
// Truncate URL for display
const truncatedUrl = webhook.url.length > 50 ?
webhook.url.substring(0, 47) + '...' : webhook.url;
// Format alert types
const alertTypes = webhook.alert_types && webhook.alert_types.length > 0 ?
webhook.alert_types.map(t => `<span class="badge bg-secondary me-1">${t}</span>`).join('') :
'<span class="text-muted">All</span>';
// Format severity filter
const severityFilter = webhook.severity_filter && webhook.severity_filter.length > 0 ?
webhook.severity_filter.map(s => `<span class="badge bg-${getSeverityColor(s)} me-1">${s}</span>`).join('') :
'<span class="text-muted">All</span>';
// Status badge
const statusBadge = webhook.enabled ?
'<span class="badge bg-success">Enabled</span>' :
'<span class="badge bg-secondary">Disabled</span>';
row.innerHTML = `
<td><strong>${escapeHtml(webhook.name)}</strong></td>
<td><code class="small">${escapeHtml(truncatedUrl)}</code></td>
<td>${alertTypes}</td>
<td>${severityFilter}</td>
<td>${statusBadge}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="testWebhook(${webhook.id})" title="Test">
<i class="bi bi-send"></i>
</button>
<a href="/webhooks/${webhook.id}/edit" class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/webhooks/${webhook.id}/logs" class="btn btn-outline-info" title="Logs">
<i class="bi bi-list-ul"></i>
</a>
<button class="btn btn-outline-danger" onclick="deleteWebhook(${webhook.id}, '${escapeHtml(webhook.name)}')" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
function renderPagination(currentPage, totalPages, totalItems) {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
if (totalPages <= 1) {
document.getElementById('pagination-container').style.display = 'none';
return;
}
document.getElementById('pagination-container').style.display = 'block';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
pagination.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
pagination.appendChild(li);
} else if (i === currentPage - 3 || i === currentPage + 3) {
const li = document.createElement('li');
li.className = 'page-item disabled';
li.innerHTML = '<a class="page-link" href="#">...</a>';
pagination.appendChild(li);
}
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
pagination.appendChild(nextLi);
}
function changePage(page) {
currentPage = page;
loadWebhooks(page);
}
async function testWebhook(id) {
if (!confirm('Send a test payload to this webhook?')) return;
try {
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
const result = await response.json();
if (result.status === 'success') {
alert(`Test successful!\nHTTP ${result.status_code}\n${result.message}`);
} else {
alert(`Test failed:\n${result.message}`);
}
} catch (error) {
console.error('Error testing webhook:', error);
alert('Failed to test webhook');
}
}
async function deleteWebhook(id, name) {
if (!confirm(`Are you sure you want to delete webhook "${name}"?`)) return;
try {
const response = await fetch(`/api/webhooks/${id}`, { method: 'DELETE' });
const result = await response.json();
if (result.status === 'success') {
alert('Webhook deleted successfully');
loadWebhooks(currentPage);
} else {
alert(`Failed to delete webhook: ${result.message}`);
}
} catch (error) {
console.error('Error deleting webhook:', error);
alert('Failed to delete webhook');
}
}
function getSeverityColor(severity) {
const colors = {
'critical': 'danger',
'warning': 'warning',
'info': 'info'
};
return colors[severity] || 'secondary';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load webhooks on page load
document.addEventListener('DOMContentLoaded', () => {
loadWebhooks(1);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,328 @@
{% extends "base.html" %}
{% block title %}Webhook Logs - {{ webhook.name }} - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12 mb-4">
<h1 style="color: #60a5fa;">Webhook Delivery Logs</h1>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('webhooks.list_webhooks') }}">Webhooks</a></li>
<li class="breadcrumb-item active">{{ webhook.name }}</li>
</ol>
</nav>
</div>
</div>
<!-- Webhook Info -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h5 class="card-title">{{ webhook.name }}</h5>
<p class="text-muted mb-1"><strong>URL:</strong> <code>{{ webhook.url }}</code></p>
<p class="text-muted mb-0">
<strong>Status:</strong>
{% if webhook.enabled %}
<span class="badge bg-success">Enabled</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</p>
</div>
<div class="col-md-6 text-md-end">
<a href="{{ url_for('webhooks.edit_webhook', webhook_id=webhook.id) }}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Edit Webhook
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="status-filter" class="form-label">Status</label>
<select class="form-select" id="status-filter">
<option value="">All</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-primary w-100" onclick="applyFilter()">
<i class="bi bi-funnel"></i> Apply Filter
</button>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="button" class="btn btn-outline-secondary w-100" onclick="refreshLogs()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading indicator -->
<div id="loading" class="text-center my-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Logs table -->
<div id="logs-container" style="display: none;">
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Alert</th>
<th>Status</th>
<th>HTTP Code</th>
<th>Attempt</th>
<th>Details</th>
</tr>
</thead>
<tbody id="logs-tbody">
<!-- Populated via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<nav aria-label="Logs pagination" id="pagination-container">
<ul class="pagination justify-content-center" id="pagination">
<!-- Populated via JavaScript -->
</ul>
</nav>
</div>
<!-- Empty state -->
<div id="empty-state" class="text-center my-5" style="display: none;">
<i class="bi bi-list-ul" style="font-size: 4rem; color: #94a3b8;"></i>
<p class="text-muted mt-3">No delivery logs yet.</p>
<p class="small text-muted">Logs will appear here after alerts trigger this webhook.</p>
</div>
<!-- Modal for log details -->
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delivery Log Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="modal-content">
<!-- Populated via JavaScript -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const webhookId = {{ webhook.id }};
let currentPage = 1;
let currentStatus = '';
const perPage = 20;
async function loadLogs(page = 1, status = '') {
try {
let url = `/api/webhooks/${webhookId}/logs?page=${page}&per_page=${perPage}`;
if (status) {
url += `&status=${status}`;
}
const response = await fetch(url);
const data = await response.json();
if (data.logs && data.logs.length > 0) {
renderLogs(data.logs);
renderPagination(data.page, data.pages, data.total);
document.getElementById('logs-container').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
} else {
document.getElementById('logs-container').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
}
} catch (error) {
console.error('Error loading logs:', error);
alert('Failed to load delivery logs');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function renderLogs(logs) {
const tbody = document.getElementById('logs-tbody');
tbody.innerHTML = '';
logs.forEach(log => {
const row = document.createElement('tr');
// Format timestamp
const timestamp = new Date(log.delivered_at).toLocaleString();
// Status badge
const statusBadge = log.status === 'success' ?
'<span class="badge bg-success">Success</span>' :
'<span class="badge bg-danger">Failed</span>';
// HTTP code badge
const httpBadge = log.response_code ?
`<span class="badge ${log.response_code < 400 ? 'bg-success' : 'bg-danger'}">${log.response_code}</span>` :
'<span class="text-muted">N/A</span>';
// Alert info
const alertInfo = log.alert_type ?
`<span class="badge bg-secondary">${log.alert_type}</span><br><small class="text-muted">${escapeHtml(log.alert_message || '')}</small>` :
`<small class="text-muted">Alert #${log.alert_id}</small>`;
row.innerHTML = `
<td><small>${timestamp}</small></td>
<td>${alertInfo}</td>
<td>${statusBadge}</td>
<td>${httpBadge}</td>
<td>${log.attempt_number || 1}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="showLogDetails(${JSON.stringify(log).replace(/"/g, '&quot;')})">
<i class="bi bi-eye"></i> View
</button>
</td>
`;
tbody.appendChild(row);
});
}
function renderPagination(currentPage, totalPages, totalItems) {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
if (totalPages <= 1) {
document.getElementById('pagination-container').style.display = 'none';
return;
}
document.getElementById('pagination-container').style.display = 'block';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">Previous</a>`;
pagination.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>`;
pagination.appendChild(li);
} else if (i === currentPage - 3 || i === currentPage + 3) {
const li = document.createElement('li');
li.className = 'page-item disabled';
li.innerHTML = '<a class="page-link" href="#">...</a>';
pagination.appendChild(li);
}
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">Next</a>`;
pagination.appendChild(nextLi);
}
function changePage(page) {
currentPage = page;
loadLogs(page, currentStatus);
}
function applyFilter() {
currentStatus = document.getElementById('status-filter').value;
currentPage = 1;
loadLogs(1, currentStatus);
}
function refreshLogs() {
loadLogs(currentPage, currentStatus);
}
function showLogDetails(log) {
const modalContent = document.getElementById('modal-content');
let detailsHTML = `
<div class="mb-3">
<strong>Log ID:</strong> ${log.id}<br>
<strong>Alert ID:</strong> ${log.alert_id}<br>
<strong>Status:</strong> <span class="badge ${log.status === 'success' ? 'bg-success' : 'bg-danger'}">${log.status}</span><br>
<strong>HTTP Code:</strong> ${log.response_code || 'N/A'}<br>
<strong>Attempt:</strong> ${log.attempt_number || 1}<br>
<strong>Delivered At:</strong> ${new Date(log.delivered_at).toLocaleString()}
</div>
`;
if (log.response_body) {
detailsHTML += `
<div class="mb-3">
<strong>Response Body:</strong>
<pre class="bg-dark text-light p-2 rounded mt-2"><code>${escapeHtml(log.response_body)}</code></pre>
</div>
`;
}
if (log.error_message) {
detailsHTML += `
<div class="mb-3">
<strong>Error Message:</strong>
<div class="alert alert-danger mt-2">${escapeHtml(log.error_message)}</div>
</div>
`;
}
modalContent.innerHTML = detailsHTML;
const modal = new bootstrap.Modal(document.getElementById('logDetailModal'));
modal.show();
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load logs on page load
document.addEventListener('DOMContentLoaded', () => {
loadLogs(1);
});
</script>
{% endblock %}

View File

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

View File

@@ -7,8 +7,8 @@ for sensitive values like passwords and API tokens.
import json
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from datetime import datetime, timezone
from typing import Any, Dict, Optional
import bcrypt
from cryptography.fernet import Fernet
@@ -32,6 +32,11 @@ class SettingsManager:
'encryption_key',
}
# Keys that are read-only (managed by developer, not user-editable)
READ_ONLY_KEYS = {
'encryption_key',
}
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
"""
Initialize the settings manager.
@@ -69,11 +74,11 @@ class SettingsManager:
return new_key
def _store_raw(self, key: str, value: str) -> None:
"""Store a setting without encryption (internal use only)."""
"""Store a setting without encryption (internal use only). Bypasses read-only check."""
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value
setting.updated_at = datetime.utcnow()
setting.updated_at = datetime.now(timezone.utc)
else:
setting = Setting(key=key, value=value)
self.db.add(setting)
@@ -128,7 +133,11 @@ class SettingsManager:
return value
def set(self, key: str, value: Any, encrypt: bool = None) -> None:
def _is_read_only(self, key: str) -> bool:
"""Check if a setting key is read-only."""
return key in self.READ_ONLY_KEYS
def set(self, key: str, value: Any, encrypt: bool = None, allow_read_only: bool = False) -> None:
"""
Set a setting value.
@@ -136,7 +145,15 @@ class SettingsManager:
key: Setting key
value: Setting value (will be JSON-encoded if dict/list)
encrypt: Force encryption on/off (None = auto-detect from ENCRYPTED_KEYS)
allow_read_only: If True, allows setting read-only keys (internal use only)
Raises:
ValueError: If attempting to set a read-only key without allow_read_only=True
"""
# Prevent modification of read-only keys unless explicitly allowed
if not allow_read_only and self._is_read_only(key):
raise ValueError(f"Setting '{key}' is read-only and cannot be modified via API")
# Convert complex types to JSON
if isinstance(value, (dict, list)):
value_str = json.dumps(value)
@@ -153,7 +170,7 @@ class SettingsManager:
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value_str
setting.updated_at = datetime.utcnow()
setting.updated_at = datetime.now(timezone.utc)
else:
setting = Setting(key=key, value=value_str)
self.db.add(setting)
@@ -251,7 +268,8 @@ class SettingsManager:
for key, value in defaults.items():
# Only set if doesn't exist
if self.db.query(Setting).filter_by(key=key).first() is None:
self.set(key, value)
# Use allow_read_only=True for initializing defaults
self.set(key, value, allow_read_only=True)
class PasswordManager:

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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"

BIN
docs/alerts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
docs/config_editor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
docs/configs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
docs/scans.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

150
setup.sh Executable file
View 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