Compare commits
6 Commits
b2a3fc7832
...
21254c3522
| Author | SHA1 | Date | |
|---|---|---|---|
| 21254c3522 | |||
| 230094d7b2 | |||
| 28b32a2049 | |||
| 1d076a467a | |||
| 3c740268c4 | |||
| 131e1f5a61 |
15
.env.example
15
.env.example
@@ -28,9 +28,10 @@ DATABASE_URL=sqlite:////app/data/sneakyscanner.db
|
|||||||
SECRET_KEY=your-secret-key-here-change-in-production
|
SECRET_KEY=your-secret-key-here-change-in-production
|
||||||
|
|
||||||
# SNEAKYSCANNER_ENCRYPTION_KEY: Used for encrypting sensitive settings in database
|
# 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())"
|
# 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
|
# CORS Configuration
|
||||||
@@ -57,8 +58,10 @@ SCHEDULER_EXECUTORS=2
|
|||||||
SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES=3
|
SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES=3
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
# Optional: Application Password
|
# Initial Password (First Run)
|
||||||
# ================================
|
# ================================
|
||||||
# If you want to set the application password via environment variable
|
# Password used for database initialization on first run
|
||||||
# Otherwise, set it via init_db.py --password
|
# This will be set as the application login password
|
||||||
# APP_PASSWORD=your-password-here
|
# Leave blank to auto-generate a random password (saved to ./logs/admin_password.txt)
|
||||||
|
# IMPORTANT: Change this after first login!
|
||||||
|
INITIAL_PASSWORD=
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,6 +9,11 @@ output/
|
|||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
admin_password.txt
|
||||||
|
logs/admin_password.txt
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -39,12 +39,13 @@ COPY app/web/ ./web/
|
|||||||
COPY app/migrations/ ./migrations/
|
COPY app/migrations/ ./migrations/
|
||||||
COPY app/alembic.ini .
|
COPY app/alembic.ini .
|
||||||
COPY app/init_db.py .
|
COPY app/init_db.py .
|
||||||
|
COPY app/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
# Create required directories
|
# Create required directories
|
||||||
RUN mkdir -p /app/output /app/logs
|
RUN mkdir -p /app/output /app/logs
|
||||||
|
|
||||||
# Make scripts executable
|
# 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
|
# Force Python unbuffered output
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -28,6 +28,28 @@ A comprehensive network scanning and infrastructure monitoring platform with web
|
|||||||
|
|
||||||
### Web Application (Recommended)
|
### 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
|
```bash
|
||||||
# 1. Clone repository
|
# 1. Clone repository
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
@@ -35,16 +57,12 @@ cd SneakyScan
|
|||||||
|
|
||||||
# 2. Configure environment
|
# 2. Configure environment
|
||||||
cp .env.example .env
|
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
|
# 3. Build and start (database auto-initializes on first run)
|
||||||
docker compose build
|
docker compose up --build -d
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 4. Initialize database
|
# 4. Access web interface
|
||||||
docker compose run --rm init-db --password "YourSecurePassword"
|
|
||||||
|
|
||||||
# 5. Access web interface
|
|
||||||
# Open http://localhost:5000
|
# Open http://localhost:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
80
app/docker-entrypoint.sh
Normal file
80
app/docker-entrypoint.sh
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# SneakyScanner Docker Entrypoint Script
|
||||||
|
# This script ensures the database is initialized before starting the Flask app
|
||||||
|
|
||||||
|
DB_PATH="${DATABASE_URL#sqlite:///}" # Extract path from sqlite:////app/data/sneakyscanner.db
|
||||||
|
DB_DIR=$(dirname "$DB_PATH")
|
||||||
|
INIT_MARKER="$DB_DIR/.db_initialized"
|
||||||
|
PASSWORD_FILE="/app/logs/admin_password.txt" # Save to logs dir (mounted, no permission issues)
|
||||||
|
|
||||||
|
echo "=== SneakyScanner Startup ==="
|
||||||
|
echo "Database path: $DB_PATH"
|
||||||
|
echo "Database directory: $DB_DIR"
|
||||||
|
|
||||||
|
# Ensure database directory exists
|
||||||
|
mkdir -p "$DB_DIR"
|
||||||
|
|
||||||
|
# Check if this is the first run (database doesn't exist or not initialized)
|
||||||
|
if [ ! -f "$DB_PATH" ] || [ ! -f "$INIT_MARKER" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== First Run Detected ==="
|
||||||
|
echo "Initializing database..."
|
||||||
|
|
||||||
|
# Set default password from environment or generate a random one
|
||||||
|
if [ -z "$INITIAL_PASSWORD" ]; then
|
||||||
|
echo "INITIAL_PASSWORD not set, generating random password..."
|
||||||
|
# Generate a 32-character alphanumeric password
|
||||||
|
INITIAL_PASSWORD=$(cat /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||||
|
# Ensure logs directory exists
|
||||||
|
mkdir -p /app/logs
|
||||||
|
echo "$INITIAL_PASSWORD" > "$PASSWORD_FILE"
|
||||||
|
echo "✓ Random password generated and saved to: ./logs/admin_password.txt"
|
||||||
|
SAVE_PASSWORD_MESSAGE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run database initialization
|
||||||
|
python3 /app/init_db.py \
|
||||||
|
--db-url "$DATABASE_URL" \
|
||||||
|
--password "$INITIAL_PASSWORD" \
|
||||||
|
--no-migrations \
|
||||||
|
--force
|
||||||
|
|
||||||
|
# Create marker file to indicate successful initialization
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
touch "$INIT_MARKER"
|
||||||
|
echo "✓ Database initialized successfully"
|
||||||
|
echo ""
|
||||||
|
echo "=== IMPORTANT ==="
|
||||||
|
if [ "$SAVE_PASSWORD_MESSAGE" = "true" ]; then
|
||||||
|
echo "Login password saved to: ./logs/admin_password.txt"
|
||||||
|
echo "Password: $INITIAL_PASSWORD"
|
||||||
|
else
|
||||||
|
echo "Login password: $INITIAL_PASSWORD"
|
||||||
|
fi
|
||||||
|
echo "Please change this password after logging in!"
|
||||||
|
echo "=================="
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "✗ Database initialization failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Database already initialized, skipping init..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apply any pending migrations (if using migrations in future)
|
||||||
|
if [ -f "/app/alembic.ini" ]; then
|
||||||
|
echo "Checking for pending migrations..."
|
||||||
|
# Uncomment when ready to use migrations:
|
||||||
|
# alembic upgrade head
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Starting Flask Application ==="
|
||||||
|
echo "Flask will be available at http://localhost:5000"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Execute the main application
|
||||||
|
exec "$@"
|
||||||
118
app/init_db.py
118
app/init_db.py
@@ -23,11 +23,112 @@ from alembic import command
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
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
|
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):
|
def init_database(db_url: str = "sqlite:///./sneakyscanner.db", run_migrations: bool = True):
|
||||||
"""
|
"""
|
||||||
Initialize the database schema and settings.
|
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 = SettingsManager(session)
|
||||||
settings_manager.init_defaults()
|
settings_manager.init_defaults()
|
||||||
print("✓ Default settings initialized")
|
print("✓ Default settings initialized")
|
||||||
|
|
||||||
|
# Initialize default alert rules
|
||||||
|
init_default_alert_rules(session)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Failed to initialize settings: {e}")
|
print(f"✗ Failed to initialize settings: {e}")
|
||||||
session.rollback()
|
session.rollback()
|
||||||
@@ -164,6 +269,9 @@ Examples:
|
|||||||
# Use custom database URL
|
# Use custom database URL
|
||||||
python3 init_db.py --db-url postgresql://user:pass@localhost/sneakyscanner
|
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
|
# Verify existing database
|
||||||
python3 init_db.py --verify-only
|
python3 init_db.py --verify-only
|
||||||
"""
|
"""
|
||||||
@@ -192,6 +300,12 @@ Examples:
|
|||||||
help='Create tables directly instead of using migrations'
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Check if database already exists
|
# Check if database already exists
|
||||||
@@ -200,7 +314,7 @@ Examples:
|
|||||||
db_path = args.db_url.replace('sqlite:///', '')
|
db_path = args.db_url.replace('sqlite:///', '')
|
||||||
db_exists = Path(db_path).exists()
|
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): ")
|
response = input(f"\nDatabase already exists at {db_path}. Reinitialize? (y/N): ")
|
||||||
if response.lower() != 'y':
|
if response.lower() != 'y':
|
||||||
print("Aborting.")
|
print("Aborting.")
|
||||||
|
|||||||
120
app/migrations/versions/004_add_alert_rule_enhancements.py
Normal file
120
app/migrations/versions/004_add_alert_rule_enhancements.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Add enhanced alert features for Phase 5
|
||||||
|
|
||||||
|
Revision ID: 004
|
||||||
|
Revises: 003
|
||||||
|
Create Date: 2025-11-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic
|
||||||
|
revision = '004'
|
||||||
|
down_revision = '003'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
"""
|
||||||
|
Add enhancements for Phase 5 Alert Rule Engine:
|
||||||
|
- Enhanced alert_rules fields
|
||||||
|
- Enhanced alerts fields
|
||||||
|
- New webhooks table
|
||||||
|
- New webhook_delivery_log table
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Enhance alert_rules table
|
||||||
|
with op.batch_alter_table('alert_rules') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('name', sa.String(255), nullable=True, comment='User-friendly rule name'))
|
||||||
|
batch_op.add_column(sa.Column('webhook_enabled', sa.Boolean(), nullable=False, server_default='0', comment='Whether to send webhooks for this rule'))
|
||||||
|
batch_op.add_column(sa.Column('severity', sa.String(20), nullable=True, comment='Alert severity level (critical, warning, info)'))
|
||||||
|
batch_op.add_column(sa.Column('filter_conditions', sa.Text(), nullable=True, comment='JSON filter conditions for the rule'))
|
||||||
|
batch_op.add_column(sa.Column('config_file', sa.String(255), nullable=True, comment='Optional: specific config file this rule applies to'))
|
||||||
|
batch_op.add_column(sa.Column('updated_at', sa.DateTime(), nullable=True, comment='Last update timestamp'))
|
||||||
|
|
||||||
|
# Enhance alerts table
|
||||||
|
with op.batch_alter_table('alerts') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('rule_id', sa.Integer(), nullable=True, comment='Associated alert rule'))
|
||||||
|
batch_op.add_column(sa.Column('webhook_sent', sa.Boolean(), nullable=False, server_default='0', comment='Whether webhook was sent'))
|
||||||
|
batch_op.add_column(sa.Column('webhook_sent_at', sa.DateTime(), nullable=True, comment='When webhook was sent'))
|
||||||
|
batch_op.add_column(sa.Column('acknowledged', sa.Boolean(), nullable=False, server_default='0', comment='Whether alert was acknowledged'))
|
||||||
|
batch_op.add_column(sa.Column('acknowledged_at', sa.DateTime(), nullable=True, comment='When alert was acknowledged'))
|
||||||
|
batch_op.add_column(sa.Column('acknowledged_by', sa.String(255), nullable=True, comment='User who acknowledged the alert'))
|
||||||
|
batch_op.create_foreign_key('fk_alerts_rule_id', 'alert_rules', ['rule_id'], ['id'])
|
||||||
|
batch_op.create_index('idx_alerts_rule_id', ['rule_id'])
|
||||||
|
batch_op.create_index('idx_alerts_acknowledged', ['acknowledged'])
|
||||||
|
|
||||||
|
# Create webhooks table
|
||||||
|
op.create_table('webhooks',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False, comment='Webhook name'),
|
||||||
|
sa.Column('url', sa.Text(), nullable=False, comment='Webhook URL'),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False, server_default='1', comment='Whether webhook is enabled'),
|
||||||
|
sa.Column('auth_type', sa.String(20), nullable=True, comment='Authentication type: none, bearer, basic, custom'),
|
||||||
|
sa.Column('auth_token', sa.Text(), nullable=True, comment='Encrypted authentication token'),
|
||||||
|
sa.Column('custom_headers', sa.Text(), nullable=True, comment='JSON custom headers'),
|
||||||
|
sa.Column('alert_types', sa.Text(), nullable=True, comment='JSON array of alert types to trigger on'),
|
||||||
|
sa.Column('severity_filter', sa.Text(), nullable=True, comment='JSON array of severities to trigger on'),
|
||||||
|
sa.Column('timeout', sa.Integer(), nullable=True, server_default='10', comment='Request timeout in seconds'),
|
||||||
|
sa.Column('retry_count', sa.Integer(), nullable=True, server_default='3', comment='Number of retry attempts'),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create webhook_delivery_log table
|
||||||
|
op.create_table('webhook_delivery_log',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('webhook_id', sa.Integer(), nullable=False, comment='Associated webhook'),
|
||||||
|
sa.Column('alert_id', sa.Integer(), nullable=False, comment='Associated alert'),
|
||||||
|
sa.Column('status', sa.String(20), nullable=True, comment='Delivery status: success, failed, retrying'),
|
||||||
|
sa.Column('response_code', sa.Integer(), nullable=True, comment='HTTP response code'),
|
||||||
|
sa.Column('response_body', sa.Text(), nullable=True, comment='Response body from webhook'),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True, comment='Error message if failed'),
|
||||||
|
sa.Column('attempt_number', sa.Integer(), nullable=True, comment='Which attempt this was'),
|
||||||
|
sa.Column('delivered_at', sa.DateTime(), nullable=False, comment='Delivery timestamp'),
|
||||||
|
sa.ForeignKeyConstraint(['webhook_id'], ['webhooks.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['alert_id'], ['alerts.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for webhook_delivery_log
|
||||||
|
op.create_index('idx_webhook_delivery_alert_id', 'webhook_delivery_log', ['alert_id'])
|
||||||
|
op.create_index('idx_webhook_delivery_webhook_id', 'webhook_delivery_log', ['webhook_id'])
|
||||||
|
op.create_index('idx_webhook_delivery_status', 'webhook_delivery_log', ['status'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""Remove Phase 5 alert enhancements."""
|
||||||
|
|
||||||
|
# Drop webhook_delivery_log table and its indexes
|
||||||
|
op.drop_index('idx_webhook_delivery_status', table_name='webhook_delivery_log')
|
||||||
|
op.drop_index('idx_webhook_delivery_webhook_id', table_name='webhook_delivery_log')
|
||||||
|
op.drop_index('idx_webhook_delivery_alert_id', table_name='webhook_delivery_log')
|
||||||
|
op.drop_table('webhook_delivery_log')
|
||||||
|
|
||||||
|
# Drop webhooks table
|
||||||
|
op.drop_table('webhooks')
|
||||||
|
|
||||||
|
# Remove enhancements from alerts table
|
||||||
|
with op.batch_alter_table('alerts') as batch_op:
|
||||||
|
batch_op.drop_index('idx_alerts_acknowledged')
|
||||||
|
batch_op.drop_index('idx_alerts_rule_id')
|
||||||
|
batch_op.drop_constraint('fk_alerts_rule_id', type_='foreignkey')
|
||||||
|
batch_op.drop_column('acknowledged_by')
|
||||||
|
batch_op.drop_column('acknowledged_at')
|
||||||
|
batch_op.drop_column('acknowledged')
|
||||||
|
batch_op.drop_column('webhook_sent_at')
|
||||||
|
batch_op.drop_column('webhook_sent')
|
||||||
|
batch_op.drop_column('rule_id')
|
||||||
|
|
||||||
|
# Remove enhancements from alert_rules table
|
||||||
|
with op.batch_alter_table('alert_rules') as batch_op:
|
||||||
|
batch_op.drop_column('updated_at')
|
||||||
|
batch_op.drop_column('config_file')
|
||||||
|
batch_op.drop_column('filter_conditions')
|
||||||
|
batch_op.drop_column('severity')
|
||||||
|
batch_op.drop_column('webhook_enabled')
|
||||||
|
batch_op.drop_column('name')
|
||||||
83
app/migrations/versions/005_add_webhook_templates.py
Normal file
83
app/migrations/versions/005_add_webhook_templates.py
Normal 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')
|
||||||
@@ -26,6 +26,9 @@ croniter==2.0.1
|
|||||||
# Email Support (Phase 4)
|
# Email Support (Phase 4)
|
||||||
Flask-Mail==0.9.1
|
Flask-Mail==0.9.1
|
||||||
|
|
||||||
|
# Webhook Support (Phase 5)
|
||||||
|
requests==2.31.0
|
||||||
|
|
||||||
# Configuration Management
|
# Configuration Management
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -4,9 +4,13 @@ Alerts API blueprint.
|
|||||||
Handles endpoints for viewing alert history and managing alert rules.
|
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.auth.decorators import api_auth_required
|
||||||
|
from web.models import Alert, AlertRule, Scan
|
||||||
|
from web.services.alert_service import AlertService
|
||||||
|
|
||||||
bp = Blueprint('alerts', __name__)
|
bp = Blueprint('alerts', __name__)
|
||||||
|
|
||||||
@@ -22,22 +26,126 @@ def list_alerts():
|
|||||||
per_page: Items per page (default: 20)
|
per_page: Items per page (default: 20)
|
||||||
alert_type: Filter by alert type
|
alert_type: Filter by alert type
|
||||||
severity: Filter by severity (info, warning, critical)
|
severity: Filter by severity (info, warning, critical)
|
||||||
start_date: Filter alerts after this date
|
acknowledged: Filter by acknowledgment status (true/false)
|
||||||
end_date: Filter alerts before this date
|
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:
|
Returns:
|
||||||
JSON response with alerts list
|
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({
|
return jsonify({
|
||||||
'alerts': [],
|
'alerts': alerts_data,
|
||||||
'total': 0,
|
'total': total,
|
||||||
'page': 1,
|
'page': page,
|
||||||
'per_page': 20,
|
'per_page': per_page,
|
||||||
'message': 'Alerts list endpoint - to be implemented in Phase 4'
|
'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'])
|
@bp.route('/rules', methods=['GET'])
|
||||||
@api_auth_required
|
@api_auth_required
|
||||||
def list_alert_rules():
|
def list_alert_rules():
|
||||||
@@ -47,10 +155,28 @@ def list_alert_rules():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON response with alert rules
|
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({
|
return jsonify({
|
||||||
'rules': [],
|
'rules': rules_data,
|
||||||
'message': 'Alert rules list endpoint - to be implemented in Phase 4'
|
'total': len(rules_data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -61,23 +187,88 @@ def create_alert_rule():
|
|||||||
Create a new alert rule.
|
Create a new alert rule.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
rule_type: Type of alert rule
|
name: User-friendly rule name
|
||||||
threshold: Threshold value (e.g., days for cert expiry)
|
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)
|
enabled: Whether rule is active (default: true)
|
||||||
email_enabled: Send email for this rule (default: false)
|
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:
|
Returns:
|
||||||
JSON response with created rule ID
|
JSON response with created rule
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 4
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
return jsonify({
|
# Validate required fields
|
||||||
'rule_id': None,
|
if not data.get('rule_type'):
|
||||||
'status': 'not_implemented',
|
return jsonify({
|
||||||
'message': 'Alert rule creation endpoint - to be implemented in Phase 4',
|
'status': 'error',
|
||||||
'data': data
|
'message': 'rule_type is required'
|
||||||
}), 501
|
}), 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'])
|
@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
|
rule_id: Alert rule ID to update
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
|
name: User-friendly rule name (optional)
|
||||||
threshold: Threshold value (optional)
|
threshold: Threshold value (optional)
|
||||||
enabled: Whether rule is active (optional)
|
enabled: Whether rule is active (optional)
|
||||||
email_enabled: Send email for this rule (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:
|
Returns:
|
||||||
JSON response with update status
|
JSON response with update status
|
||||||
"""
|
"""
|
||||||
# TODO: Implement in Phase 4
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
return jsonify({
|
# Get existing rule
|
||||||
'rule_id': rule_id,
|
rule = current_app.db_session.query(AlertRule).filter(AlertRule.id == rule_id).first()
|
||||||
'status': 'not_implemented',
|
if not rule:
|
||||||
'message': 'Alert rule update endpoint - to be implemented in Phase 4',
|
return jsonify({
|
||||||
'data': data
|
'status': 'error',
|
||||||
}), 501
|
'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'])
|
@bp.route('/rules/<int:rule_id>', methods=['DELETE'])
|
||||||
@@ -120,12 +373,83 @@ def delete_alert_rule(rule_id):
|
|||||||
Returns:
|
Returns:
|
||||||
JSON response with deletion status
|
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({
|
return jsonify({
|
||||||
'rule_id': rule_id,
|
'stats': {
|
||||||
'status': 'not_implemented',
|
'total_alerts': total_alerts,
|
||||||
'message': 'Alert rule deletion endpoint - to be implemented in Phase 4'
|
'unacknowledged_count': unacknowledged_count,
|
||||||
}), 501
|
'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
|
# Health check endpoint
|
||||||
@@ -140,5 +464,5 @@ def health_check():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'api': 'alerts',
|
'api': 'alerts',
|
||||||
'version': '1.0.0-phase1'
|
'version': '1.0.0-phase5'
|
||||||
})
|
})
|
||||||
@@ -75,6 +75,12 @@ def update_settings():
|
|||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Updated {len(settings_dict)} settings'
|
'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:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Failed to update settings: {e}")
|
current_app.logger.error(f"Failed to update settings: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -112,7 +118,8 @@ def get_setting(key):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'key': key,
|
'key': key,
|
||||||
'value': value
|
'value': value,
|
||||||
|
'read_only': settings_manager._is_read_only(key)
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
|
current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
|
||||||
@@ -154,6 +161,12 @@ def update_setting(key):
|
|||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Setting "{key}" updated'
|
'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:
|
except Exception as e:
|
||||||
current_app.logger.error(f"Failed to update setting {key}: {e}")
|
current_app.logger.error(f"Failed to update setting {key}: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -176,6 +189,14 @@ def delete_setting(key):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
settings_manager = get_settings_manager()
|
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)
|
deleted = settings_manager.delete(key)
|
||||||
|
|
||||||
if not deleted:
|
if not deleted:
|
||||||
|
|||||||
677
app/web/api/webhooks.py
Normal file
677
app/web/api/webhooks.py
Normal 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'
|
||||||
|
})
|
||||||
@@ -95,6 +95,9 @@ def create_app(config: dict = None) -> Flask:
|
|||||||
# Register error handlers
|
# Register error handlers
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
# Register context processors
|
||||||
|
register_context_processors(app)
|
||||||
|
|
||||||
# Add request/response handlers
|
# Add request/response handlers
|
||||||
register_request_handlers(app)
|
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.scans import bp as scans_bp
|
||||||
from web.api.schedules import bp as schedules_bp
|
from web.api.schedules import bp as schedules_bp
|
||||||
from web.api.alerts import bp as alerts_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.settings import bp as settings_bp
|
||||||
from web.api.stats import bp as stats_bp
|
from web.api.stats import bp as stats_bp
|
||||||
from web.api.configs import bp as configs_bp
|
from web.api.configs import bp as configs_bp
|
||||||
from web.auth.routes import bp as auth_bp
|
from web.auth.routes import bp as auth_bp
|
||||||
from web.routes.main import bp as main_bp
|
from web.routes.main import bp as main_bp
|
||||||
|
from web.routes.webhooks import bp as webhooks_bp
|
||||||
|
|
||||||
# Register authentication blueprint
|
# Register authentication blueprint
|
||||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||||
@@ -340,10 +345,14 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
# Register main web routes blueprint
|
# Register main web routes blueprint
|
||||||
app.register_blueprint(main_bp, url_prefix='/')
|
app.register_blueprint(main_bp, url_prefix='/')
|
||||||
|
|
||||||
|
# Register webhooks web routes blueprint
|
||||||
|
app.register_blueprint(webhooks_bp, url_prefix='/webhooks')
|
||||||
|
|
||||||
# Register API blueprints
|
# Register API blueprints
|
||||||
app.register_blueprint(scans_bp, url_prefix='/api/scans')
|
app.register_blueprint(scans_bp, url_prefix='/api/scans')
|
||||||
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
|
||||||
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
|
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(settings_bp, url_prefix='/api/settings')
|
||||||
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
||||||
app.register_blueprint(configs_bp, url_prefix='/api/configs')
|
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
|
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:
|
def register_request_handlers(app: Flask) -> None:
|
||||||
"""
|
"""
|
||||||
Register request and response handlers.
|
Register request and response handlers.
|
||||||
|
|||||||
13
app/web/config.py
Normal file
13
app/web/config.py
Normal 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'
|
||||||
@@ -16,6 +16,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from src.scanner import SneakyScanner
|
from src.scanner import SneakyScanner
|
||||||
from web.models import Scan
|
from web.models import Scan
|
||||||
from web.services.scan_service import ScanService
|
from web.services.scan_service import ScanService
|
||||||
|
from web.services.alert_service import AlertService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 = ScanService(session)
|
||||||
scan_service._save_scan_to_db(report, scan_id, status='completed')
|
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")
|
logger.info(f"Scan {scan_id}: Completed successfully")
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
|||||||
59
app/web/jobs/webhook_job.py
Normal file
59
app/web/jobs/webhook_job.py
Normal 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}")
|
||||||
@@ -284,17 +284,24 @@ class Alert(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=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")
|
severity = Column(String(20), nullable=False, comment="info, warning, critical")
|
||||||
message = Column(Text, nullable=False, comment="Human-readable alert message")
|
message = Column(Text, nullable=False, comment="Human-readable alert message")
|
||||||
ip_address = Column(String(45), nullable=True, comment="Related IP (optional)")
|
ip_address = Column(String(45), nullable=True, comment="Related IP (optional)")
|
||||||
port = Column(Integer, nullable=True, comment="Related port (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 = Column(Boolean, nullable=False, default=False, comment="Was email notification sent?")
|
||||||
email_sent_at = Column(DateTime, nullable=True, comment="Email send timestamp")
|
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")
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Alert creation time")
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
scan = relationship('Scan', back_populates='alerts')
|
scan = relationship('Scan', back_populates='alerts')
|
||||||
|
rule = relationship('AlertRule', back_populates='alerts')
|
||||||
|
|
||||||
# Index for alert queries by type and severity
|
# Index for alert queries by type and severity
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -315,14 +322,82 @@ class AlertRule(Base):
|
|||||||
__tablename__ = 'alert_rules'
|
__tablename__ = 'alert_rules'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
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?")
|
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)")
|
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?")
|
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")
|
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):
|
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}')>"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -219,3 +219,105 @@ def edit_config(filename):
|
|||||||
logger.error(f"Error loading config for edit: {e}")
|
logger.error(f"Error loading config for edit: {e}")
|
||||||
flash(f"Error loading config: {str(e)}", 'error')
|
flash(f"Error loading config: {str(e)}", 'error')
|
||||||
return redirect(url_for('main.configs'))
|
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
|
||||||
|
)
|
||||||
|
|||||||
83
app/web/routes/webhooks.py
Normal file
83
app/web/routes/webhooks.py
Normal 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)
|
||||||
521
app/web/services/alert_service.py
Normal file
521
app/web/services/alert_service.py
Normal 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()
|
||||||
|
)
|
||||||
294
app/web/services/template_service.py
Normal file
294
app/web/services/template_service.py
Normal 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
|
||||||
566
app/web/services/webhook_service.py
Normal file
566
app/web/services/webhook_service.py
Normal 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
|
||||||
|
}
|
||||||
474
app/web/templates/alert_rules.html
Normal file
474
app/web/templates/alert_rules.html
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Alert Rules - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: #60a5fa;">Alert Rules</h1>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('main.alerts') }}" class="btn btn-outline-primary me-2">
|
||||||
|
<i class="bi bi-bell"></i> View Alerts
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-primary" onclick="showCreateRuleModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rule Statistics -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-2">Total Rules</h6>
|
||||||
|
<h3 class="mb-0" style="color: #60a5fa;">{{ rules | length }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-2">Active Rules</h6>
|
||||||
|
<h3 class="mb-0 text-success">{{ rules | selectattr('enabled') | list | length }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Alert Rules Configuration</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if rules %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Threshold</th>
|
||||||
|
<th>Config</th>
|
||||||
|
<th>Notifications</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for rule in rules %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ rule.name or 'Unnamed Rule' }}</strong>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">ID: {{ rule.id }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
{{ rule.rule_type.replace('_', ' ').title() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if rule.severity == 'critical' %}
|
||||||
|
<span class="badge bg-danger">Critical</span>
|
||||||
|
{% elif rule.severity == 'warning' %}
|
||||||
|
<span class="badge bg-warning">Warning</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-info">{{ rule.severity or 'Info' }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if rule.threshold %}
|
||||||
|
{% if rule.rule_type == 'cert_expiry' %}
|
||||||
|
{{ rule.threshold }} days
|
||||||
|
{% elif rule.rule_type == 'drift_detection' %}
|
||||||
|
{{ rule.threshold }}%
|
||||||
|
{% else %}
|
||||||
|
{{ rule.threshold }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if rule.config_file %}
|
||||||
|
<small class="text-muted">{{ rule.config_file }}</small>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-primary">All Configs</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if rule.email_enabled %}
|
||||||
|
<i class="bi bi-envelope-fill text-primary" title="Email enabled"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% if rule.webhook_enabled %}
|
||||||
|
<i class="bi bi-send-fill text-primary" title="Webhook enabled"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% if not rule.email_enabled and not rule.webhook_enabled %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="rule-enabled-{{ rule.id }}"
|
||||||
|
{% if rule.enabled %}checked{% endif %}
|
||||||
|
onchange="toggleRule({{ rule.id }}, this.checked)">
|
||||||
|
<label class="form-check-label" for="rule-enabled-{{ rule.id }}">
|
||||||
|
{% if rule.enabled %}
|
||||||
|
<span class="text-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="editRule({{ rule.id }})">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteRule({{ rule.id }}, '{{ rule.name }}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="mt-3">No alert rules configured</h5>
|
||||||
|
<p>Create alert rules to be notified of important scan findings.</p>
|
||||||
|
<button class="btn btn-primary mt-3" onclick="showCreateRuleModal()">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Your First Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Rule Modal -->
|
||||||
|
<div class="modal fade" id="ruleModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="ruleModalTitle">Create Alert Rule</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="ruleForm">
|
||||||
|
<input type="hidden" id="rule-id">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="rule-name" class="form-label">Rule Name</label>
|
||||||
|
<input type="text" class="form-control" id="rule-name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="rule-type" class="form-label">Rule Type</label>
|
||||||
|
<select class="form-select" id="rule-type" required onchange="updateThresholdLabel()">
|
||||||
|
<option value="">Select a type...</option>
|
||||||
|
<option value="unexpected_port">Unexpected Port Detection</option>
|
||||||
|
<option value="drift_detection">Drift Detection</option>
|
||||||
|
<option value="cert_expiry">Certificate Expiry</option>
|
||||||
|
<option value="weak_tls">Weak TLS Version</option>
|
||||||
|
<option value="ping_failed">Ping Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="rule-severity" class="form-label">Severity</label>
|
||||||
|
<select class="form-select" id="rule-severity" required>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="warning" selected>Warning</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="rule-threshold" class="form-label" id="threshold-label">Threshold</label>
|
||||||
|
<input type="number" class="form-control" id="rule-threshold">
|
||||||
|
<small class="form-text text-muted" id="threshold-help">
|
||||||
|
Numeric value that triggers the alert (varies by rule type)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12 mb-3">
|
||||||
|
<label for="rule-config" class="form-label">Apply to Config (optional)</label>
|
||||||
|
<select class="form-select" id="rule-config">
|
||||||
|
<option value="">All Configs (Apply to all scans)</option>
|
||||||
|
{% if config_files %}
|
||||||
|
{% for config_file in config_files %}
|
||||||
|
<option value="{{ config_file }}">{{ config_file }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<option value="" disabled>No config files found</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">
|
||||||
|
{% if config_files %}
|
||||||
|
Select a specific config file to limit this rule, or leave as "All Configs" to apply to all scans
|
||||||
|
{% else %}
|
||||||
|
No config files found. Upload a config in the Configs section to see available options.
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="rule-email">
|
||||||
|
<label class="form-check-label" for="rule-email">
|
||||||
|
Send Email Notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="rule-webhook">
|
||||||
|
<label class="form-check-label" for="rule-webhook">
|
||||||
|
Send Webhook Notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="rule-enabled" checked>
|
||||||
|
<label class="form-check-label" for="rule-enabled">
|
||||||
|
Enable this rule immediately
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveRule()">
|
||||||
|
<span id="save-rule-text">Create Rule</span>
|
||||||
|
<span id="save-rule-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let editingRuleId = null;
|
||||||
|
|
||||||
|
function showCreateRuleModal() {
|
||||||
|
editingRuleId = null;
|
||||||
|
document.getElementById('ruleModalTitle').textContent = 'Create Alert Rule';
|
||||||
|
document.getElementById('save-rule-text').textContent = 'Create Rule';
|
||||||
|
document.getElementById('ruleForm').reset();
|
||||||
|
document.getElementById('rule-enabled').checked = true;
|
||||||
|
new bootstrap.Modal(document.getElementById('ruleModal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRule(ruleId) {
|
||||||
|
editingRuleId = ruleId;
|
||||||
|
document.getElementById('ruleModalTitle').textContent = 'Edit Alert Rule';
|
||||||
|
document.getElementById('save-rule-text').textContent = 'Update Rule';
|
||||||
|
|
||||||
|
// Fetch rule details
|
||||||
|
fetch(`/api/alerts/rules`, {
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const rule = data.rules.find(r => r.id === ruleId);
|
||||||
|
if (rule) {
|
||||||
|
document.getElementById('rule-id').value = rule.id;
|
||||||
|
document.getElementById('rule-name').value = rule.name || '';
|
||||||
|
document.getElementById('rule-type').value = rule.rule_type;
|
||||||
|
document.getElementById('rule-severity').value = rule.severity || 'warning';
|
||||||
|
document.getElementById('rule-threshold').value = rule.threshold || '';
|
||||||
|
document.getElementById('rule-config').value = rule.config_file || '';
|
||||||
|
document.getElementById('rule-email').checked = rule.email_enabled;
|
||||||
|
document.getElementById('rule-webhook').checked = rule.webhook_enabled;
|
||||||
|
document.getElementById('rule-enabled').checked = rule.enabled;
|
||||||
|
|
||||||
|
updateThresholdLabel();
|
||||||
|
new bootstrap.Modal(document.getElementById('ruleModal')).show();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching rule:', error);
|
||||||
|
alert('Failed to load rule details');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThresholdLabel() {
|
||||||
|
const ruleType = document.getElementById('rule-type').value;
|
||||||
|
const label = document.getElementById('threshold-label');
|
||||||
|
const help = document.getElementById('threshold-help');
|
||||||
|
|
||||||
|
switch(ruleType) {
|
||||||
|
case 'cert_expiry':
|
||||||
|
label.textContent = 'Days Before Expiry';
|
||||||
|
help.textContent = 'Alert when certificate expires within this many days (default: 30)';
|
||||||
|
break;
|
||||||
|
case 'drift_detection':
|
||||||
|
label.textContent = 'Drift Percentage';
|
||||||
|
help.textContent = 'Alert when drift exceeds this percentage (0-100, default: 5)';
|
||||||
|
break;
|
||||||
|
case 'unexpected_port':
|
||||||
|
label.textContent = 'Threshold (optional)';
|
||||||
|
help.textContent = 'Leave blank - this rule alerts on any port not in your config file';
|
||||||
|
break;
|
||||||
|
case 'weak_tls':
|
||||||
|
label.textContent = 'Threshold (optional)';
|
||||||
|
help.textContent = 'Leave blank - this rule alerts on TLS versions below 1.2';
|
||||||
|
break;
|
||||||
|
case 'ping_failed':
|
||||||
|
label.textContent = 'Threshold (optional)';
|
||||||
|
help.textContent = 'Leave blank - this rule alerts when a host fails to respond to ping';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label.textContent = 'Threshold';
|
||||||
|
help.textContent = 'Numeric value that triggers the alert (select a rule type for specific guidance)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRule() {
|
||||||
|
const name = document.getElementById('rule-name').value;
|
||||||
|
const ruleType = document.getElementById('rule-type').value;
|
||||||
|
const severity = document.getElementById('rule-severity').value;
|
||||||
|
const threshold = document.getElementById('rule-threshold').value;
|
||||||
|
const configFile = document.getElementById('rule-config').value;
|
||||||
|
const emailEnabled = document.getElementById('rule-email').checked;
|
||||||
|
const webhookEnabled = document.getElementById('rule-webhook').checked;
|
||||||
|
const enabled = document.getElementById('rule-enabled').checked;
|
||||||
|
|
||||||
|
if (!name || !ruleType) {
|
||||||
|
alert('Please fill in required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: name,
|
||||||
|
rule_type: ruleType,
|
||||||
|
severity: severity,
|
||||||
|
threshold: threshold ? parseInt(threshold) : null,
|
||||||
|
config_file: configFile || null,
|
||||||
|
email_enabled: emailEnabled,
|
||||||
|
webhook_enabled: webhookEnabled,
|
||||||
|
enabled: enabled
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show spinner
|
||||||
|
document.getElementById('save-rule-text').style.display = 'none';
|
||||||
|
document.getElementById('save-rule-spinner').style.display = 'inline-block';
|
||||||
|
|
||||||
|
const url = editingRuleId
|
||||||
|
? `/api/alerts/rules/${editingRuleId}`
|
||||||
|
: '/api/alerts/rules';
|
||||||
|
const method = editingRuleId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to save rule: ' + (data.message || 'Unknown error'));
|
||||||
|
// Hide spinner
|
||||||
|
document.getElementById('save-rule-text').style.display = 'inline';
|
||||||
|
document.getElementById('save-rule-spinner').style.display = 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to save rule');
|
||||||
|
// Hide spinner
|
||||||
|
document.getElementById('save-rule-text').style.display = 'inline';
|
||||||
|
document.getElementById('save-rule-spinner').style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRule(ruleId, enabled) {
|
||||||
|
fetch(`/api/alerts/rules/${ruleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: enabled })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
alert('Failed to update rule status');
|
||||||
|
// Revert checkbox
|
||||||
|
document.getElementById(`rule-enabled-${ruleId}`).checked = !enabled;
|
||||||
|
} else {
|
||||||
|
// Update label
|
||||||
|
const label = document.querySelector(`label[for="rule-enabled-${ruleId}"] span`);
|
||||||
|
if (enabled) {
|
||||||
|
label.className = 'text-success';
|
||||||
|
label.textContent = 'Active';
|
||||||
|
} else {
|
||||||
|
label.className = 'text-muted';
|
||||||
|
label.textContent = 'Inactive';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to update rule status');
|
||||||
|
// Revert checkbox
|
||||||
|
document.getElementById(`rule-enabled-${ruleId}`).checked = !enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRule(ruleId, ruleName) {
|
||||||
|
if (!confirm(`Delete alert rule "${ruleName}"? This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/alerts/rules/${ruleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete rule: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to delete rule');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
269
app/web/templates/alerts.html
Normal file
269
app/web/templates/alerts.html
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Alerts - SneakyScanner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 style="color: #60a5fa;">Alert History</h1>
|
||||||
|
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-gear"></i> Manage Alert Rules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert Statistics -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-2">Total Alerts</h6>
|
||||||
|
<h3 class="mb-0" style="color: #60a5fa;">{{ pagination.total }}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-2">Critical</h6>
|
||||||
|
<h3 class="mb-0 text-danger">
|
||||||
|
{{ alerts | selectattr('severity', 'equalto', 'critical') | list | length }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-2">Warnings</h6>
|
||||||
|
<h3 class="mb-0 text-warning">
|
||||||
|
{{ alerts | selectattr('severity', 'equalto', 'warning') | list | length }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="text-muted mb-2">Unacknowledged</h6>
|
||||||
|
<h3 class="mb-0" style="color: #f97316;">
|
||||||
|
{{ alerts | rejectattr('acknowledged') | list | length }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="{{ url_for('main.alerts') }}" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="severity-filter" class="form-label">Severity</label>
|
||||||
|
<select class="form-select" id="severity-filter" name="severity">
|
||||||
|
<option value="">All Severities</option>
|
||||||
|
<option value="critical" {% if current_severity == 'critical' %}selected{% endif %}>Critical</option>
|
||||||
|
<option value="warning" {% if current_severity == 'warning' %}selected{% endif %}>Warning</option>
|
||||||
|
<option value="info" {% if current_severity == 'info' %}selected{% endif %}>Info</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="type-filter" class="form-label">Alert Type</label>
|
||||||
|
<select class="form-select" id="type-filter" name="alert_type">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{% for at in alert_types %}
|
||||||
|
<option value="{{ at }}" {% if current_alert_type == at %}selected{% endif %}>
|
||||||
|
{{ at.replace('_', ' ').title() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="ack-filter" class="form-label">Acknowledgment</label>
|
||||||
|
<select class="form-select" id="ack-filter" name="acknowledged">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="false" {% if current_acknowledged == 'false' %}selected{% endif %}>Unacknowledged</option>
|
||||||
|
<option value="true" {% if current_acknowledged == 'true' %}selected{% endif %}>Acknowledged</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-funnel"></i> Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts Table -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0" style="color: #60a5fa;">Alerts</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if alerts %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 100px;">Severity</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th style="width: 120px;">Target</th>
|
||||||
|
<th style="width: 150px;">Scan</th>
|
||||||
|
<th style="width: 150px;">Created</th>
|
||||||
|
<th style="width: 100px;">Status</th>
|
||||||
|
<th style="width: 100px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for alert in alerts %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if alert.severity == 'critical' %}
|
||||||
|
<span class="badge bg-danger">Critical</span>
|
||||||
|
{% elif alert.severity == 'warning' %}
|
||||||
|
<span class="badge bg-warning">Warning</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-info">Info</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-muted">{{ alert.alert_type.replace('_', ' ').title() }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ alert.message }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if alert.ip_address %}
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ alert.ip_address }}{% if alert.port %}:{{ alert.port }}{% endif %}
|
||||||
|
</small>
|
||||||
|
{% else %}
|
||||||
|
<small class="text-muted">-</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('main.scan_detail', scan_id=alert.scan_id) }}" class="text-decoration-none">
|
||||||
|
Scan #{{ alert.scan_id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small class="text-muted">{{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if alert.acknowledged %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-check-circle"></i> Ack'd
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">New</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if alert.email_sent %}
|
||||||
|
<i class="bi bi-envelope-fill text-muted" title="Email sent"></i>
|
||||||
|
{% endif %}
|
||||||
|
{% if alert.webhook_sent %}
|
||||||
|
<i class="bi bi-send-fill text-muted" title="Webhook sent"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not alert.acknowledged %}
|
||||||
|
<button class="btn btn-sm btn-outline-success" onclick="acknowledgeAlert({{ alert.id }})">
|
||||||
|
<i class="bi bi-check"></i> Ack
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<small class="text-muted" title="Acknowledged by {{ alert.acknowledged_by }} at {{ alert.acknowledged_at.strftime('%Y-%m-%d %H:%M') }}">
|
||||||
|
By: {{ alert.acknowledged_by }}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<nav aria-label="Alert pagination" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('main.alerts', page=pagination.prev_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% for page_num in pagination.iter_pages(left_edge=1, left_current=1, right_current=2, right_edge=1) %}
|
||||||
|
{% if page_num %}
|
||||||
|
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('main.alerts', page=page_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
|
||||||
|
{{ page_num }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled">
|
||||||
|
<span class="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('main.alerts', page=pagination.next_num, severity=current_severity, alert_type=current_alert_type, acknowledged=current_acknowledged) }}">
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-bell-slash" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="mt-3">No alerts found</h5>
|
||||||
|
<p>Alerts will appear here when scan results trigger alert rules.</p>
|
||||||
|
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary mt-3">
|
||||||
|
<i class="bi bi-plus-circle"></i> Configure Alert Rules
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function acknowledgeAlert(alertId) {
|
||||||
|
if (!confirm('Acknowledge this alert?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/alerts/${alertId}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Key': localStorage.getItem('api_key') || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
acknowledged_by: 'web_user'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to acknowledge alert: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Failed to acknowledge alert');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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 -->
|
<!-- Bootstrap 5 CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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">
|
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
|
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
|
||||||
SneakyScanner
|
{{ app_name }}
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
@@ -57,6 +57,18 @@
|
|||||||
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
|
||||||
href="{{ url_for('main.configs') }}">Configs</a>
|
href="{{ url_for('main.configs') }}">Configs</a>
|
||||||
</li>
|
</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>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -85,7 +97,7 @@
|
|||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="container-fluid">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
9
app/web/templates/webhook_presets/custom_json.j2
Normal file
9
app/web/templates/webhook_presets/custom_json.j2
Normal 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() }}"
|
||||||
|
}
|
||||||
25
app/web/templates/webhook_presets/default_json.j2
Normal file
25
app/web/templates/webhook_presets/default_json.j2
Normal 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' }}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/web/templates/webhook_presets/discord.j2
Normal file
41
app/web/templates/webhook_presets/discord.j2
Normal 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() }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
app/web/templates/webhook_presets/gotify.j2
Normal file
13
app/web/templates/webhook_presets/gotify.j2
Normal 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 }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/web/templates/webhook_presets/ntfy.j2
Normal file
10
app/web/templates/webhook_presets/ntfy.j2
Normal 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 }}
|
||||||
27
app/web/templates/webhook_presets/plain_text.j2
Normal file
27
app/web/templates/webhook_presets/plain_text.j2
Normal 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 }}
|
||||||
65
app/web/templates/webhook_presets/presets.json
Normal file
65
app/web/templates/webhook_presets/presets.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
60
app/web/templates/webhook_presets/slack.j2
Normal file
60
app/web/templates/webhook_presets/slack.j2
Normal 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') }}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
633
app/web/templates/webhooks/form.html
Normal file
633
app/web/templates/webhooks/form.html
Normal 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 %}
|
||||||
250
app/web/templates/webhooks/list.html
Normal file
250
app/web/templates/webhooks/list.html
Normal 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 %}
|
||||||
328
app/web/templates/webhooks/logs.html
Normal file
328
app/web/templates/webhooks/logs.html
Normal 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, '"')})">
|
||||||
|
<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 %}
|
||||||
@@ -53,6 +53,46 @@ class PaginatedResult:
|
|||||||
"""Get next page number."""
|
"""Get next page number."""
|
||||||
return self.page + 1 if self.has_next else None
|
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]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Convert to dictionary for API responses.
|
Convert to dictionary for API responses.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ for sensitive values like passwords and API tokens.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
@@ -32,6 +32,11 @@ class SettingsManager:
|
|||||||
'encryption_key',
|
'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):
|
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
|
||||||
"""
|
"""
|
||||||
Initialize the settings manager.
|
Initialize the settings manager.
|
||||||
@@ -69,11 +74,11 @@ class SettingsManager:
|
|||||||
return new_key
|
return new_key
|
||||||
|
|
||||||
def _store_raw(self, key: str, value: str) -> None:
|
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()
|
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||||
if setting:
|
if setting:
|
||||||
setting.value = value
|
setting.value = value
|
||||||
setting.updated_at = datetime.utcnow()
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
setting = Setting(key=key, value=value)
|
setting = Setting(key=key, value=value)
|
||||||
self.db.add(setting)
|
self.db.add(setting)
|
||||||
@@ -128,7 +133,11 @@ class SettingsManager:
|
|||||||
|
|
||||||
return value
|
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.
|
Set a setting value.
|
||||||
|
|
||||||
@@ -136,7 +145,15 @@ class SettingsManager:
|
|||||||
key: Setting key
|
key: Setting key
|
||||||
value: Setting value (will be JSON-encoded if dict/list)
|
value: Setting value (will be JSON-encoded if dict/list)
|
||||||
encrypt: Force encryption on/off (None = auto-detect from ENCRYPTED_KEYS)
|
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
|
# Convert complex types to JSON
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
value_str = json.dumps(value)
|
value_str = json.dumps(value)
|
||||||
@@ -153,7 +170,7 @@ class SettingsManager:
|
|||||||
setting = self.db.query(Setting).filter_by(key=key).first()
|
setting = self.db.query(Setting).filter_by(key=key).first()
|
||||||
if setting:
|
if setting:
|
||||||
setting.value = value_str
|
setting.value = value_str
|
||||||
setting.updated_at = datetime.utcnow()
|
setting.updated_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
setting = Setting(key=key, value=value_str)
|
setting = Setting(key=key, value=value_str)
|
||||||
self.db.add(setting)
|
self.db.add(setting)
|
||||||
@@ -251,7 +268,8 @@ class SettingsManager:
|
|||||||
for key, value in defaults.items():
|
for key, value in defaults.items():
|
||||||
# Only set if doesn't exist
|
# Only set if doesn't exist
|
||||||
if self.db.query(Setting).filter_by(key=key).first() is None:
|
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:
|
class PasswordManager:
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
image: sneakyscanner:latest
|
image: sneakyscanner:latest
|
||||||
container_name: sneakyscanner-web
|
container_name: sneakyscanner-web
|
||||||
# Override entrypoint to run Flask app instead of scanner
|
# Use entrypoint script that auto-initializes database on first run
|
||||||
entrypoint: ["python3", "-u"]
|
entrypoint: ["/docker-entrypoint.sh"]
|
||||||
command: ["-m", "web.app"]
|
command: ["python3", "-u", "-m", "web.app"]
|
||||||
# Note: Using host network mode for scanner capabilities, so no port mapping needed
|
# Note: Using host network mode for scanner capabilities, so no port mapping needed
|
||||||
# The Flask app will be accessible at http://localhost:5000
|
# The Flask app will be accessible at http://localhost:5000
|
||||||
volumes:
|
volumes:
|
||||||
@@ -28,7 +28,10 @@ services:
|
|||||||
- FLASK_PORT=5000
|
- FLASK_PORT=5000
|
||||||
# Database configuration (SQLite in mounted volume for persistence)
|
# Database configuration (SQLite in mounted volume for persistence)
|
||||||
- DATABASE_URL=sqlite:////app/data/sneakyscanner.db
|
- DATABASE_URL=sqlite:////app/data/sneakyscanner.db
|
||||||
|
# Initial password for first run (leave empty to auto-generate)
|
||||||
|
- INITIAL_PASSWORD=${INITIAL_PASSWORD:-}
|
||||||
# Security settings
|
# 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}
|
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
|
||||||
- SNEAKYSCANNER_ENCRYPTION_KEY=${SNEAKYSCANNER_ENCRYPTION_KEY:-}
|
- SNEAKYSCANNER_ENCRYPTION_KEY=${SNEAKYSCANNER_ENCRYPTION_KEY:-}
|
||||||
# Optional: CORS origins (comma-separated)
|
# Optional: CORS origins (comma-separated)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,31 @@ docker compose version
|
|||||||
|
|
||||||
For users who want to get started immediately with the web application:
|
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
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
@@ -82,18 +107,17 @@ cd SneakyScan
|
|||||||
# 2. Create environment file
|
# 2. Create environment file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env and set SECRET_KEY and SNEAKYSCANNER_ENCRYPTION_KEY
|
# Edit .env and set SECRET_KEY and SNEAKYSCANNER_ENCRYPTION_KEY
|
||||||
|
# Optionally set INITIAL_PASSWORD (leave blank for auto-generation)
|
||||||
nano .env
|
nano .env
|
||||||
|
|
||||||
# 3. Build the Docker image
|
# 3. Build and start (database auto-initializes on first run)
|
||||||
docker compose build
|
docker compose up --build -d
|
||||||
|
|
||||||
# 4. Initialize the database and set password
|
# 4. Check logs for auto-generated password (if not set in .env)
|
||||||
docker compose run --rm init-db --password "YourSecurePassword"
|
docker compose logs web | grep "Password"
|
||||||
|
# Or check: ./logs/admin_password.txt
|
||||||
|
|
||||||
# 5. Start the application
|
# 5. Access the web interface
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 6. Access the web interface
|
|
||||||
# Open browser to: http://localhost:5000
|
# Open browser to: http://localhost:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -126,7 +150,10 @@ SneakyScanner is configured via environment variables. The recommended approach
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Generate secure keys
|
# Generate secure keys
|
||||||
|
# SECRET_KEY: Flask session secret (64-character hex string)
|
||||||
python3 -c "import secrets; print('SECRET_KEY=' + secrets.token_hex(32))" >> .env
|
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
|
python3 -c "from cryptography.fernet import Fernet; print('SNEAKYSCANNER_ENCRYPTION_KEY=' + Fernet.generate_key().decode())" >> .env
|
||||||
|
|
||||||
# Edit other settings as needed
|
# 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** |
|
| `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** |
|
| `SNEAKYSCANNER_ENCRYPTION_KEY` | Encryption key for sensitive settings | (empty) | **Yes** |
|
||||||
| `DATABASE_URL` | SQLite database path | `sqlite:////app/data/sneakyscanner.db` | 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 |
|
| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | `INFO` | No |
|
||||||
| `SCHEDULER_EXECUTORS` | Number of concurrent scan threads | `2` | No |
|
| `SCHEDULER_EXECUTORS` | Number of concurrent scan threads | `2` | No |
|
||||||
| `SCHEDULER_JOB_DEFAULTS_MAX_INSTANCES` | Max instances of same job | `3` | 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
|
### 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
|
```bash
|
||||||
# Initialize database and set application password
|
# 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:
|
# The init-db command will:
|
||||||
# - Create database schema
|
# - Create database schema
|
||||||
# - Run all Alembic migrations
|
|
||||||
# - Set the application password (bcrypt hashed)
|
# - Set the application password (bcrypt hashed)
|
||||||
# - Create default settings with encryption
|
# - Create default settings with encryption
|
||||||
|
# - Create default alert rules
|
||||||
|
|
||||||
# Verify database was created
|
# Verify database was created
|
||||||
ls -lh data/sneakyscanner.db
|
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
|
- Minimum 8 characters recommended
|
||||||
- Use a strong, unique password
|
- Use a strong, unique password
|
||||||
- Store securely (password manager)
|
- 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
|
### Step 5: Verify Configuration
|
||||||
|
|
||||||
@@ -699,17 +755,25 @@ tail -f logs/sneakyscanner.log
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check logs for errors
|
# Check logs for errors
|
||||||
docker compose -f docker-compose.yml logs web
|
docker compose logs web
|
||||||
|
|
||||||
# Common issues:
|
# Common issues:
|
||||||
# 1. Database not initialized - run init-db first
|
# 1. Permission issues with volumes - check directory ownership
|
||||||
# 2. Permission issues with volumes - check directory ownership
|
# 2. Port 5000 already in use - change FLASK_PORT or stop conflicting service
|
||||||
# 3. 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
|
### Database Initialization Fails
|
||||||
|
|
||||||
**Problem**: `init_db.py` fails with errors
|
**Problem**: Automatic database initialization fails on first run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check database directory permissions
|
# Check database directory permissions
|
||||||
@@ -718,12 +782,37 @@ ls -la data/
|
|||||||
# Fix permissions if needed
|
# Fix permissions if needed
|
||||||
sudo chown -R $USER:$USER data/
|
sudo chown -R $USER:$USER data/
|
||||||
|
|
||||||
# Verify SQLite is accessible
|
# View initialization logs
|
||||||
sqlite3 data/sneakyscanner.db "SELECT 1;" 2>&1
|
docker compose logs web | grep -A 50 "Initializing database"
|
||||||
|
|
||||||
# Remove corrupted database and reinitialize
|
# Clean up and retry initialization
|
||||||
rm data/sneakyscanner.db
|
docker compose down
|
||||||
docker compose -f docker-compose.yml run --rm init-db --password "YourPassword"
|
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"
|
### Scans Fail with "Permission Denied"
|
||||||
|
|||||||
BIN
docs/alerts.png
Normal file
BIN
docs/alerts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/config_editor.png
Normal file
BIN
docs/config_editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/configs.png
Normal file
BIN
docs/configs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/scans.png
Normal file
BIN
docs/scans.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
150
setup.sh
Executable file
150
setup.sh
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# SneakyScanner First-Run Setup Script
|
||||||
|
# This script helps you get started quickly with SneakyScanner
|
||||||
|
|
||||||
|
echo "================================================"
|
||||||
|
echo " SneakyScanner - First Run Setup"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to generate random key for Flask SECRET_KEY
|
||||||
|
generate_secret_key() {
|
||||||
|
openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to generate Fernet encryption key (32 url-safe base64-encoded bytes)
|
||||||
|
generate_fernet_key() {
|
||||||
|
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>/dev/null || \
|
||||||
|
openssl rand -base64 32 | head -c 44
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "✓ .env file already exists"
|
||||||
|
read -p "Do you want to regenerate it? (y/N): " REGENERATE
|
||||||
|
if [ "$REGENERATE" != "y" ] && [ "$REGENERATE" != "Y" ]; then
|
||||||
|
echo "Skipping .env creation..."
|
||||||
|
SKIP_ENV=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create or update .env
|
||||||
|
if [ "$SKIP_ENV" != "true" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Creating .env file..."
|
||||||
|
|
||||||
|
# Generate secure keys
|
||||||
|
SECRET_KEY=$(generate_secret_key)
|
||||||
|
ENCRYPTION_KEY=$(generate_fernet_key)
|
||||||
|
|
||||||
|
# Ask for initial password
|
||||||
|
echo ""
|
||||||
|
echo "Set an initial password for the web interface:"
|
||||||
|
read -s -p "Password (or press Enter to generate random password): " INITIAL_PASSWORD
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ -z "$INITIAL_PASSWORD" ]; then
|
||||||
|
echo "Generating random password..."
|
||||||
|
# Generate a 32-character alphanumeric password
|
||||||
|
INITIAL_PASSWORD=$(cat /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 32)
|
||||||
|
# Save password to file in project root (avoid permission issues with mounted volumes)
|
||||||
|
echo "$INITIAL_PASSWORD" > admin_password.txt
|
||||||
|
echo "✓ Random password generated and saved to: ./admin_password.txt"
|
||||||
|
PASSWORD_SAVED=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create .env file
|
||||||
|
cat > .env << EOF
|
||||||
|
# Flask Configuration
|
||||||
|
FLASK_ENV=production
|
||||||
|
FLASK_DEBUG=false
|
||||||
|
|
||||||
|
# Security Keys (randomly generated)
|
||||||
|
SECRET_KEY=$SECRET_KEY
|
||||||
|
SNEAKYSCANNER_ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||||
|
|
||||||
|
# Initial Password
|
||||||
|
INITIAL_PASSWORD=$INITIAL_PASSWORD
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=sqlite:////app/data/sneakyscanner.db
|
||||||
|
|
||||||
|
# Optional: Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Optional: CORS (comma-separated origins, or * for all)
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✓ .env file created with secure keys"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
echo ""
|
||||||
|
echo "Creating required directories..."
|
||||||
|
mkdir -p data logs output configs
|
||||||
|
echo "✓ Directories created"
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
echo ""
|
||||||
|
echo "Checking Docker..."
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
echo "✗ Docker is not running or not installed"
|
||||||
|
echo "Please install Docker and start the Docker daemon"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✓ Docker is running"
|
||||||
|
|
||||||
|
# Build and start
|
||||||
|
echo ""
|
||||||
|
echo "Building and starting SneakyScanner..."
|
||||||
|
echo "This may take a few minutes on first run..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Starting SneakyScanner..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Wait for service to be healthy
|
||||||
|
echo ""
|
||||||
|
echo "Waiting for application to start..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if docker ps | grep -q sneakyscanner-web; then
|
||||||
|
echo ""
|
||||||
|
echo "================================================"
|
||||||
|
echo " ✓ SneakyScanner is Running!"
|
||||||
|
echo "================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Web Interface: http://localhost:5000"
|
||||||
|
echo ""
|
||||||
|
echo "Login with:"
|
||||||
|
if [ -z "$SKIP_ENV" ]; then
|
||||||
|
if [ "$PASSWORD_SAVED" = "true" ]; then
|
||||||
|
echo " Password saved in: ./admin_password.txt"
|
||||||
|
echo " Password: $INITIAL_PASSWORD"
|
||||||
|
else
|
||||||
|
echo " Password: $INITIAL_PASSWORD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " Password: (check your .env file or ./admin_password.txt)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " docker compose logs -f # View logs"
|
||||||
|
echo " docker compose stop # Stop the service"
|
||||||
|
echo " docker compose restart # Restart the service"
|
||||||
|
echo ""
|
||||||
|
echo "⚠ IMPORTANT: Change your password after first login!"
|
||||||
|
echo "================================================"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "✗ Container failed to start. Check logs with:"
|
||||||
|
echo " docker compose logs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user