restructure of dirs, huge docs update

This commit is contained in:
2025-11-17 16:29:14 -06:00
parent 456e052389
commit cd840cb8ca
87 changed files with 2827 additions and 1094 deletions

114
app/alembic.ini Normal file
View File

@@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///./sneakyscanner.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

235
app/init_db.py Executable file
View File

@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
Database initialization script for SneakyScanner.
This script:
1. Creates the database schema using Alembic migrations
2. Initializes default settings
3. Optionally sets up an initial admin password
Usage:
python3 init_db.py [--password PASSWORD]
"""
import argparse
import os
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent))
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.models import Base
from web.utils.settings import PasswordManager, SettingsManager
def init_database(db_url: str = "sqlite:///./sneakyscanner.db", run_migrations: bool = True):
"""
Initialize the database schema and settings.
Args:
db_url: Database URL (defaults to SQLite in current directory)
run_migrations: Whether to run Alembic migrations (True) or create all tables directly (False)
"""
print(f"Initializing SneakyScanner database at: {db_url}")
# Create database directory if it doesn't exist (for SQLite)
if db_url.startswith('sqlite:///'):
db_path = db_url.replace('sqlite:///', '')
db_dir = Path(db_path).parent
if not db_dir.exists():
print(f"Creating database directory: {db_dir}")
db_dir.mkdir(parents=True, exist_ok=True)
if run_migrations:
# Run Alembic migrations
print("Running Alembic migrations...")
alembic_cfg = Config("alembic.ini")
alembic_cfg.set_main_option("sqlalchemy.url", db_url)
try:
# Upgrade to head (latest migration)
command.upgrade(alembic_cfg, "head")
print("✓ Database schema created successfully via migrations")
except Exception as e:
print(f"✗ Migration failed: {e}")
print("Falling back to direct table creation...")
run_migrations = False
if not run_migrations:
# Create tables directly using SQLAlchemy (fallback or if migrations disabled)
print("Creating database schema directly...")
engine = create_engine(db_url, echo=False)
Base.metadata.create_all(engine)
print("✓ Database schema created successfully")
# Initialize settings
print("\nInitializing default settings...")
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
settings_manager = SettingsManager(session)
settings_manager.init_defaults()
print("✓ Default settings initialized")
except Exception as e:
print(f"✗ Failed to initialize settings: {e}")
session.rollback()
raise
finally:
session.close()
print("\n✓ Database initialization complete!")
return True
def set_password(db_url: str, password: str):
"""
Set the application password.
Args:
db_url: Database URL
password: Password to set
"""
print("Setting application password...")
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
settings_manager = SettingsManager(session)
PasswordManager.set_app_password(settings_manager, password)
print("✓ Password set successfully")
except Exception as e:
print(f"✗ Failed to set password: {e}")
session.rollback()
raise
finally:
session.close()
def verify_database(db_url: str):
"""
Verify database schema and settings.
Args:
db_url: Database URL
"""
print("\nVerifying database...")
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Check if tables exist by querying settings
from web.models import Setting
count = session.query(Setting).count()
print(f"✓ Settings table accessible ({count} settings found)")
# Display current settings (sanitized)
settings_manager = SettingsManager(session)
settings = settings_manager.get_all(decrypt=False, sanitize=True)
print("\nCurrent settings:")
for key, value in sorted(settings.items()):
print(f" {key}: {value}")
except Exception as e:
print(f"✗ Database verification failed: {e}")
raise
finally:
session.close()
def main():
"""Main entry point for database initialization."""
parser = argparse.ArgumentParser(
description="Initialize SneakyScanner database",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Initialize database with default settings
python3 init_db.py
# Initialize and set password
python3 init_db.py --password mysecretpassword
# Use custom database URL
python3 init_db.py --db-url postgresql://user:pass@localhost/sneakyscanner
# Verify existing database
python3 init_db.py --verify-only
"""
)
parser.add_argument(
'--db-url',
default='sqlite:///./sneakyscanner.db',
help='Database URL (default: sqlite:///./sneakyscanner.db)'
)
parser.add_argument(
'--password',
help='Set application password'
)
parser.add_argument(
'--verify-only',
action='store_true',
help='Only verify database, do not initialize'
)
parser.add_argument(
'--no-migrations',
action='store_true',
help='Create tables directly instead of using migrations'
)
args = parser.parse_args()
# Check if database already exists
db_exists = False
if args.db_url.startswith('sqlite:///'):
db_path = args.db_url.replace('sqlite:///', '')
db_exists = Path(db_path).exists()
if db_exists and not args.verify_only:
response = input(f"\nDatabase already exists at {db_path}. Reinitialize? (y/N): ")
if response.lower() != 'y':
print("Aborting.")
return
try:
if args.verify_only:
verify_database(args.db_url)
else:
# Initialize database
init_database(args.db_url, run_migrations=not args.no_migrations)
# Set password if provided
if args.password:
set_password(args.db_url, args.password)
# Verify
verify_database(args.db_url)
print("\n✓ All done! Database is ready to use.")
if not args.password and not args.verify_only:
print("\n⚠ WARNING: No password set. Run with --password to set one:")
print(f" python3 init_db.py --db-url {args.db_url} --password YOUR_PASSWORD")
except Exception as e:
print(f"\n✗ Initialization failed: {e}")
sys.exit(1)
if __name__ == '__main__':
main()

83
app/migrations/env.py Normal file
View File

@@ -0,0 +1,83 @@
"""Alembic migration environment for SneakyScanner."""
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import all models to ensure they're registered with Base
from web.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,221 @@
"""Initial database schema for SneakyScanner
Revision ID: 001
Revises:
Create Date: 2025-11-13 18:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create all initial tables for SneakyScanner."""
# Create schedules table first (referenced by scans)
op.create_table('schedules',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=255), nullable=False, comment='Schedule name (e.g., \'Daily prod scan\')'),
sa.Column('config_file', sa.Text(), nullable=False, comment='Path to YAML config'),
sa.Column('cron_expression', sa.String(length=100), nullable=False, comment='Cron-like schedule (e.g., \'0 2 * * *\')'),
sa.Column('enabled', sa.Boolean(), nullable=False, comment='Is schedule active?'),
sa.Column('last_run', sa.DateTime(), nullable=True, comment='Last execution time'),
sa.Column('next_run', sa.DateTime(), nullable=True, comment='Next scheduled execution'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Schedule creation time'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
sa.PrimaryKeyConstraint('id')
)
# Create scans table
op.create_table('scans',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False, comment='Scan start time (UTC)'),
sa.Column('duration', sa.Float(), nullable=True, comment='Total scan duration in seconds'),
sa.Column('status', sa.String(length=20), nullable=False, comment='running, completed, failed'),
sa.Column('config_file', sa.Text(), nullable=True, comment='Path to YAML config used'),
sa.Column('title', sa.Text(), nullable=True, comment='Scan title from config'),
sa.Column('json_path', sa.Text(), nullable=True, comment='Path to JSON report'),
sa.Column('html_path', sa.Text(), nullable=True, comment='Path to HTML report'),
sa.Column('zip_path', sa.Text(), nullable=True, comment='Path to ZIP archive'),
sa.Column('screenshot_dir', sa.Text(), nullable=True, comment='Path to screenshot directory'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Record creation time'),
sa.Column('triggered_by', sa.String(length=50), nullable=False, comment='manual, scheduled, api'),
sa.Column('schedule_id', sa.Integer(), nullable=True, comment='FK to schedules if triggered by schedule'),
sa.ForeignKeyConstraint(['schedule_id'], ['schedules.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scans_timestamp'), 'scans', ['timestamp'], unique=False)
# Create scan_sites table
op.create_table('scan_sites',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
sa.Column('site_name', sa.String(length=255), nullable=False, comment='Site name from config'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_sites_scan_id'), 'scan_sites', ['scan_id'], unique=False)
# Create scan_ips table
op.create_table('scan_ips',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
sa.Column('site_id', sa.Integer(), nullable=False, comment='FK to scan_sites'),
sa.Column('ip_address', sa.String(length=45), nullable=False, comment='IPv4 or IPv6 address'),
sa.Column('ping_expected', sa.Boolean(), nullable=True, comment='Expected ping response'),
sa.Column('ping_actual', sa.Boolean(), nullable=True, comment='Actual ping response'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
sa.ForeignKeyConstraint(['site_id'], ['scan_sites.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scan_id', 'ip_address', name='uix_scan_ip')
)
op.create_index(op.f('ix_scan_ips_scan_id'), 'scan_ips', ['scan_id'], unique=False)
op.create_index(op.f('ix_scan_ips_site_id'), 'scan_ips', ['site_id'], unique=False)
# Create scan_ports table
op.create_table('scan_ports',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
sa.Column('ip_id', sa.Integer(), nullable=False, comment='FK to scan_ips'),
sa.Column('port', sa.Integer(), nullable=False, comment='Port number (1-65535)'),
sa.Column('protocol', sa.String(length=10), nullable=False, comment='tcp or udp'),
sa.Column('expected', sa.Boolean(), nullable=True, comment='Was this port expected?'),
sa.Column('state', sa.String(length=20), nullable=False, comment='open, closed, filtered'),
sa.ForeignKeyConstraint(['ip_id'], ['scan_ips.id'], ),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scan_id', 'ip_id', 'port', 'protocol', name='uix_scan_ip_port')
)
op.create_index(op.f('ix_scan_ports_ip_id'), 'scan_ports', ['ip_id'], unique=False)
op.create_index(op.f('ix_scan_ports_scan_id'), 'scan_ports', ['scan_id'], unique=False)
# Create scan_services table
op.create_table('scan_services',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
sa.Column('port_id', sa.Integer(), nullable=False, comment='FK to scan_ports'),
sa.Column('service_name', sa.String(length=100), nullable=True, comment='Service name (e.g., ssh, http)'),
sa.Column('product', sa.String(length=255), nullable=True, comment='Product name (e.g., OpenSSH)'),
sa.Column('version', sa.String(length=100), nullable=True, comment='Version string'),
sa.Column('extrainfo', sa.Text(), nullable=True, comment='Additional nmap info'),
sa.Column('ostype', sa.String(length=100), nullable=True, comment='OS type if detected'),
sa.Column('http_protocol', sa.String(length=10), nullable=True, comment='http or https (if web service)'),
sa.Column('screenshot_path', sa.Text(), nullable=True, comment='Relative path to screenshot'),
sa.ForeignKeyConstraint(['port_id'], ['scan_ports.id'], ),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_services_port_id'), 'scan_services', ['port_id'], unique=False)
op.create_index(op.f('ix_scan_services_scan_id'), 'scan_services', ['scan_id'], unique=False)
# Create scan_certificates table
op.create_table('scan_certificates',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
sa.Column('service_id', sa.Integer(), nullable=False, comment='FK to scan_services'),
sa.Column('subject', sa.Text(), nullable=True, comment='Certificate subject (CN)'),
sa.Column('issuer', sa.Text(), nullable=True, comment='Certificate issuer'),
sa.Column('serial_number', sa.Text(), nullable=True, comment='Serial number'),
sa.Column('not_valid_before', sa.DateTime(), nullable=True, comment='Validity start date'),
sa.Column('not_valid_after', sa.DateTime(), nullable=True, comment='Validity end date'),
sa.Column('days_until_expiry', sa.Integer(), nullable=True, comment='Days until expiration'),
sa.Column('sans', sa.Text(), nullable=True, comment='JSON array of SANs'),
sa.Column('is_self_signed', sa.Boolean(), nullable=True, comment='Self-signed certificate flag'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
sa.ForeignKeyConstraint(['service_id'], ['scan_services.id'], ),
sa.PrimaryKeyConstraint('id'),
comment='Index on expiration date for alert queries'
)
op.create_index(op.f('ix_scan_certificates_scan_id'), 'scan_certificates', ['scan_id'], unique=False)
op.create_index(op.f('ix_scan_certificates_service_id'), 'scan_certificates', ['service_id'], unique=False)
# Create scan_tls_versions table
op.create_table('scan_tls_versions',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
sa.Column('certificate_id', sa.Integer(), nullable=False, comment='FK to scan_certificates'),
sa.Column('tls_version', sa.String(length=20), nullable=False, comment='TLS 1.0, TLS 1.1, TLS 1.2, TLS 1.3'),
sa.Column('supported', sa.Boolean(), nullable=False, comment='Is this version supported?'),
sa.Column('cipher_suites', sa.Text(), nullable=True, comment='JSON array of cipher suites'),
sa.ForeignKeyConstraint(['certificate_id'], ['scan_certificates.id'], ),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_tls_versions_certificate_id'), 'scan_tls_versions', ['certificate_id'], unique=False)
op.create_index(op.f('ix_scan_tls_versions_scan_id'), 'scan_tls_versions', ['scan_id'], unique=False)
# Create alerts table
op.create_table('alerts',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scan_id', sa.Integer(), nullable=False, comment='FK to scans'),
sa.Column('alert_type', sa.String(length=50), nullable=False, comment='new_port, cert_expiry, service_change, ping_failed'),
sa.Column('severity', sa.String(length=20), nullable=False, comment='info, warning, critical'),
sa.Column('message', sa.Text(), nullable=False, comment='Human-readable alert message'),
sa.Column('ip_address', sa.String(length=45), nullable=True, comment='Related IP (optional)'),
sa.Column('port', sa.Integer(), nullable=True, comment='Related port (optional)'),
sa.Column('email_sent', sa.Boolean(), nullable=False, comment='Was email notification sent?'),
sa.Column('email_sent_at', sa.DateTime(), nullable=True, comment='Email send timestamp'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Alert creation time'),
sa.ForeignKeyConstraint(['scan_id'], ['scans.id'], ),
sa.PrimaryKeyConstraint('id'),
comment='Indexes for alert filtering'
)
op.create_index(op.f('ix_alerts_scan_id'), 'alerts', ['scan_id'], unique=False)
# Create alert_rules table
op.create_table('alert_rules',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('rule_type', sa.String(length=50), nullable=False, comment='unexpected_port, cert_expiry, service_down, etc.'),
sa.Column('enabled', sa.Boolean(), nullable=False, comment='Is rule active?'),
sa.Column('threshold', sa.Integer(), nullable=True, comment='Threshold value (e.g., days for cert expiry)'),
sa.Column('email_enabled', sa.Boolean(), nullable=False, comment='Send email for this rule?'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='Rule creation time'),
sa.PrimaryKeyConstraint('id')
)
# Create settings table
op.create_table('settings',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('key', sa.String(length=255), nullable=False, comment='Setting key (e.g., smtp_server)'),
sa.Column('value', sa.Text(), nullable=True, comment='Setting value (JSON for complex values)'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='Last modification time'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_index(op.f('ix_settings_key'), 'settings', ['key'], unique=True)
def downgrade() -> None:
"""Drop all tables."""
op.drop_index(op.f('ix_settings_key'), table_name='settings')
op.drop_table('settings')
op.drop_table('alert_rules')
op.drop_index(op.f('ix_alerts_scan_id'), table_name='alerts')
op.drop_table('alerts')
op.drop_index(op.f('ix_scan_tls_versions_scan_id'), table_name='scan_tls_versions')
op.drop_index(op.f('ix_scan_tls_versions_certificate_id'), table_name='scan_tls_versions')
op.drop_table('scan_tls_versions')
op.drop_index(op.f('ix_scan_certificates_service_id'), table_name='scan_certificates')
op.drop_index(op.f('ix_scan_certificates_scan_id'), table_name='scan_certificates')
op.drop_table('scan_certificates')
op.drop_index(op.f('ix_scan_services_scan_id'), table_name='scan_services')
op.drop_index(op.f('ix_scan_services_port_id'), table_name='scan_services')
op.drop_table('scan_services')
op.drop_index(op.f('ix_scan_ports_scan_id'), table_name='scan_ports')
op.drop_index(op.f('ix_scan_ports_ip_id'), table_name='scan_ports')
op.drop_table('scan_ports')
op.drop_index(op.f('ix_scan_ips_site_id'), table_name='scan_ips')
op.drop_index(op.f('ix_scan_ips_scan_id'), table_name='scan_ips')
op.drop_table('scan_ips')
op.drop_index(op.f('ix_scan_sites_scan_id'), table_name='scan_sites')
op.drop_table('scan_sites')
op.drop_index(op.f('ix_scans_timestamp'), table_name='scans')
op.drop_table('scans')
op.drop_table('schedules')

View File

@@ -0,0 +1,28 @@
"""Add indexes for scan queries
Revision ID: 002
Revises: 001
Create Date: 2025-11-14 00:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add database indexes for better query performance."""
# Add index on scans.status for filtering
# Note: index on scans.timestamp already exists from migration 001
op.create_index('ix_scans_status', 'scans', ['status'], unique=False)
def downgrade() -> None:
"""Remove indexes."""
op.drop_index('ix_scans_status', table_name='scans')

View File

@@ -0,0 +1,39 @@
"""Add timing and error fields to scans table
Revision ID: 003
Revises: 002
Create Date: 2025-11-14
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade():
"""
Add fields for tracking scan execution timing and errors.
New fields:
- started_at: When scan execution actually started
- completed_at: When scan execution finished (success or failure)
- error_message: Error message if scan failed
"""
with op.batch_alter_table('scans') as batch_op:
batch_op.add_column(sa.Column('started_at', sa.DateTime(), nullable=True, comment='Scan execution start time'))
batch_op.add_column(sa.Column('completed_at', sa.DateTime(), nullable=True, comment='Scan execution completion time'))
batch_op.add_column(sa.Column('error_message', sa.Text(), nullable=True, comment='Error message if scan failed'))
def downgrade():
"""Remove the timing and error fields."""
with op.batch_alter_table('scans') as batch_op:
batch_op.drop_column('error_message')
batch_op.drop_column('completed_at')
batch_op.drop_column('started_at')

34
app/requirements-web.txt Normal file
View File

@@ -0,0 +1,34 @@
# Flask Web Application Dependencies
# Phase 1: Foundation (Database, Settings, Flask Core)
# Core Flask
Flask==3.0.0
Werkzeug==3.0.1
# Database & ORM
SQLAlchemy==2.0.23
alembic==1.13.0
# Authentication & Security
Flask-Login==0.6.3
bcrypt==4.1.2
cryptography==41.0.7
# API & Serialization
Flask-CORS==4.0.0
marshmallow==3.20.1
marshmallow-sqlalchemy==0.29.0
# Background Jobs & Scheduling
APScheduler==3.10.4
croniter==2.0.1
# Email Support (Phase 4)
Flask-Mail==0.9.1
# Configuration Management
python-dotenv==1.0.0
# Development & Testing
pytest==7.4.3
pytest-flask==1.3.0

5
app/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
PyYAML==6.0.1
python-libnmap==0.7.3
sslyze==6.0.0
playwright==1.40.0
Jinja2==3.1.2

327
app/src/report_generator.py Executable file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
HTML Report Generator for SneakyScanner
Generates comprehensive HTML reports from JSON scan results with:
- Summary dashboard (statistics, drift alerts, security warnings)
- Site-by-site breakdown with service details
- SSL/TLS certificate and cipher suite information
- Visual badges for expected vs. unexpected services
"""
import json
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class HTMLReportGenerator:
"""Generates HTML reports from SneakyScanner JSON output."""
def __init__(self, json_report_path: str, template_dir: str = 'templates'):
"""
Initialize the HTML report generator.
Args:
json_report_path: Path to the JSON scan report
template_dir: Directory containing Jinja2 templates
"""
self.json_report_path = Path(json_report_path)
self.template_dir = Path(template_dir)
self.report_data = None
# Initialize Jinja2 environment
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
# Register custom filters
self.jinja_env.filters['format_date'] = self._format_date
self.jinja_env.filters['format_duration'] = self._format_duration
def generate_report(self, output_path: Optional[str] = None) -> str:
"""
Generate HTML report from JSON scan data.
Args:
output_path: Path for output HTML file. If None, derives from JSON filename.
Returns:
Path to generated HTML report
"""
logger.info(f"Loading JSON report from {self.json_report_path}")
self._load_json_report()
logger.info("Calculating summary statistics")
summary_stats = self._calculate_summary_stats()
logger.info("Identifying drift alerts")
drift_alerts = self._identify_drift_alerts()
logger.info("Identifying security warnings")
security_warnings = self._identify_security_warnings()
# Prepare template context
context = {
'title': self.report_data.get('title', 'SneakyScanner Report'),
'scan_time': self.report_data.get('scan_time'),
'scan_duration': self.report_data.get('scan_duration'),
'config_file': self.report_data.get('config_file'),
'sites': self.report_data.get('sites', []),
'summary_stats': summary_stats,
'drift_alerts': drift_alerts,
'security_warnings': security_warnings,
}
# Determine output path
if output_path is None:
output_path = self.json_report_path.with_suffix('.html')
else:
output_path = Path(output_path)
logger.info("Rendering HTML template")
template = self.jinja_env.get_template('report_template.html')
html_content = template.render(**context)
logger.info(f"Writing HTML report to {output_path}")
output_path.write_text(html_content, encoding='utf-8')
logger.info(f"Successfully generated HTML report: {output_path}")
return str(output_path)
def _load_json_report(self) -> None:
"""Load and parse JSON scan report."""
if not self.json_report_path.exists():
raise FileNotFoundError(f"JSON report not found: {self.json_report_path}")
with open(self.json_report_path, 'r') as f:
self.report_data = json.load(f)
def _calculate_summary_stats(self) -> Dict[str, int]:
"""
Calculate summary statistics for the dashboard.
Returns:
Dictionary with stat counts
"""
stats = {
'total_ips': 0,
'tcp_ports': 0,
'udp_ports': 0,
'services': 0,
'web_services': 0,
'screenshots': 0,
}
for site in self.report_data.get('sites', []):
for ip_data in site.get('ips', []):
stats['total_ips'] += 1
actual = ip_data.get('actual', {})
stats['tcp_ports'] += len(actual.get('tcp_ports', []))
stats['udp_ports'] += len(actual.get('udp_ports', []))
services = actual.get('services', [])
stats['services'] += len(services)
# Count web services (HTTP/HTTPS)
for service in services:
if service.get('http_info'):
stats['web_services'] += 1
if service['http_info'].get('screenshot'):
stats['screenshots'] += 1
return stats
def _identify_drift_alerts(self) -> Dict[str, int]:
"""
Identify infrastructure drift (unexpected/missing items).
Returns:
Dictionary with drift alert counts
"""
alerts = {
'unexpected_tcp': 0,
'unexpected_udp': 0,
'missing_tcp': 0,
'missing_udp': 0,
'new_services': 0,
}
for site in self.report_data.get('sites', []):
for ip_data in site.get('ips', []):
expected = ip_data.get('expected', {})
actual = ip_data.get('actual', {})
expected_tcp = set(expected.get('tcp_ports', []))
actual_tcp = set(actual.get('tcp_ports', []))
expected_udp = set(expected.get('udp_ports', []))
actual_udp = set(actual.get('udp_ports', []))
# Count unexpected ports
alerts['unexpected_tcp'] += len(actual_tcp - expected_tcp)
alerts['unexpected_udp'] += len(actual_udp - expected_udp)
# Count missing ports
alerts['missing_tcp'] += len(expected_tcp - actual_tcp)
alerts['missing_udp'] += len(expected_udp - actual_udp)
# Count new services (any service on unexpected port)
unexpected_ports = (actual_tcp - expected_tcp) | (actual_udp - expected_udp)
for service in actual.get('services', []):
if service.get('port') in unexpected_ports:
alerts['new_services'] += 1
return alerts
def _identify_security_warnings(self) -> Dict[str, Any]:
"""
Identify security issues (cert expiry, weak TLS, etc.).
Returns:
Dictionary with security warning counts and details
"""
warnings = {
'expiring_certs': 0,
'weak_tls': 0,
'self_signed': 0,
'high_ports': 0,
'expiring_cert_details': [], # List of IPs with expiring certs
}
for site in self.report_data.get('sites', []):
for ip_data in site.get('ips', []):
actual = ip_data.get('actual', {})
for service in actual.get('services', []):
port = service.get('port')
# Check for high ports (>10000)
if port and port > 10000:
warnings['high_ports'] += 1
# Check SSL/TLS if present
http_info = service.get('http_info', {})
ssl_tls = http_info.get('ssl_tls', {})
if ssl_tls:
# Check certificate expiry
cert = ssl_tls.get('certificate', {})
days_until_expiry = cert.get('days_until_expiry')
if days_until_expiry is not None and days_until_expiry < 30:
warnings['expiring_certs'] += 1
warnings['expiring_cert_details'].append({
'ip': ip_data.get('address'),
'port': port,
'days': days_until_expiry,
'subject': cert.get('subject'),
})
# Check for self-signed
issuer = cert.get('issuer', '')
subject = cert.get('subject', '')
if issuer and subject and issuer == subject:
warnings['self_signed'] += 1
# Check for weak TLS versions
tls_versions = ssl_tls.get('tls_versions', {})
if tls_versions.get('TLS 1.0', {}).get('supported'):
warnings['weak_tls'] += 1
elif tls_versions.get('TLS 1.1', {}).get('supported'):
warnings['weak_tls'] += 1
return warnings
@staticmethod
def _format_date(date_str: Optional[str]) -> str:
"""
Format ISO date string for display.
Args:
date_str: ISO format date string
Returns:
Formatted date string
"""
if not date_str:
return 'N/A'
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime('%Y-%m-%d %H:%M:%S UTC')
except (ValueError, AttributeError):
return str(date_str)
@staticmethod
def _format_duration(duration: Optional[float]) -> str:
"""
Format scan duration for display.
Args:
duration: Duration in seconds
Returns:
Formatted duration string
"""
if duration is None:
return 'N/A'
if duration < 60:
return f"{duration:.1f} seconds"
elif duration < 3600:
minutes = duration / 60
return f"{minutes:.1f} minutes"
else:
hours = duration / 3600
return f"{hours:.2f} hours"
def main():
"""Command-line entry point for standalone usage."""
if len(sys.argv) < 2:
print("Usage: python report_generator.py <json_report_path> [output_html_path]")
print("\nExample:")
print(" python report_generator.py output/scan_report_20251114_103000.json")
print(" python report_generator.py output/scan_report.json custom_report.html")
sys.exit(1)
json_path = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else None
try:
# Determine template directory relative to script location
script_dir = Path(__file__).parent.parent
template_dir = script_dir / 'templates'
generator = HTMLReportGenerator(json_path, template_dir=str(template_dir))
result_path = generator.generate_report(output_path)
print(f"\n✓ Successfully generated HTML report:")
print(f" {result_path}")
except FileNotFoundError as e:
logger.error(f"File not found: {e}")
sys.exit(1)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in report file: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"Error generating report: {e}", exc_info=True)
sys.exit(1)
if __name__ == '__main__':
main()

826
app/src/scanner.py Normal file
View File

@@ -0,0 +1,826 @@
#!/usr/bin/env python3
"""
SneakyScanner - Masscan-based network scanner with YAML configuration
"""
import argparse
import json
import logging
import subprocess
import sys
import tempfile
import time
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any
import xml.etree.ElementTree as ET
import yaml
from libnmap.process import NmapProcess
from libnmap.parser import NmapParser
from src.screenshot_capture import ScreenshotCapture
from src.report_generator import HTMLReportGenerator
# Force unbuffered output for Docker
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
class SneakyScanner:
"""Wrapper for masscan to perform network scans based on YAML config"""
def __init__(self, config_path: str, output_dir: str = "/app/output"):
self.config_path = Path(config_path)
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.config = self._load_config()
self.screenshot_capture = None
def _load_config(self) -> Dict[str, Any]:
"""Load and validate YAML configuration"""
if not self.config_path.exists():
raise FileNotFoundError(f"Config file not found: {self.config_path}")
with open(self.config_path, 'r') as f:
config = yaml.safe_load(f)
if not config.get('title'):
raise ValueError("Config must include 'title' field")
if not config.get('sites'):
raise ValueError("Config must include 'sites' field")
return config
def _run_masscan(self, targets: List[str], ports: str, protocol: str) -> List[Dict]:
"""
Run masscan and return parsed results
Args:
targets: List of IP addresses to scan
ports: Port range string (e.g., "0-65535")
protocol: "tcp" or "udp"
"""
if not targets:
return []
# Create temporary file for targets
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write('\n'.join(targets))
target_file = f.name
# Create temporary output file
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
output_file = f.name
try:
# Build command based on protocol
if protocol == 'tcp':
cmd = [
'masscan',
'-iL', target_file,
'-p', ports,
'--rate', '10000',
'-oJ', output_file,
'--wait', '0'
]
elif protocol == 'udp':
cmd = [
'masscan',
'-iL', target_file,
'--udp-ports', ports,
'--rate', '10000',
'-oJ', output_file,
'--wait', '0'
]
else:
raise ValueError(f"Invalid protocol: {protocol}")
print(f"Running: {' '.join(cmd)}", flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
print(f"Masscan {protocol.upper()} scan completed", flush=True)
if result.returncode != 0:
print(f"Masscan stderr: {result.stderr}", file=sys.stderr)
# Parse masscan JSON output
results = []
with open(output_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
try:
results.append(json.loads(line.rstrip(',')))
except json.JSONDecodeError:
continue
return results
finally:
# Cleanup temp files
Path(target_file).unlink(missing_ok=True)
Path(output_file).unlink(missing_ok=True)
def _run_ping_scan(self, targets: List[str]) -> Dict[str, bool]:
"""
Run ping scan using masscan ICMP echo
Returns:
Dict mapping IP addresses to ping response status
"""
if not targets:
return {}
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write('\n'.join(targets))
target_file = f.name
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as f:
output_file = f.name
try:
cmd = [
'masscan',
'-iL', target_file,
'--ping',
'--rate', '10000',
'-oJ', output_file,
'--wait', '0'
]
print(f"Running: {' '.join(cmd)}", flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
print(f"Masscan PING scan completed", flush=True)
if result.returncode != 0:
print(f"Masscan stderr: {result.stderr}", file=sys.stderr, flush=True)
# Parse results
responding_ips = set()
with open(output_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
try:
data = json.loads(line.rstrip(','))
if 'ip' in data:
responding_ips.add(data['ip'])
except json.JSONDecodeError:
continue
# Create result dict for all targets
return {ip: (ip in responding_ips) for ip in targets}
finally:
Path(target_file).unlink(missing_ok=True)
Path(output_file).unlink(missing_ok=True)
def _run_nmap_service_detection(self, ip_ports: Dict[str, List[int]]) -> Dict[str, List[Dict]]:
"""
Run nmap service detection on discovered ports
Args:
ip_ports: Dict mapping IP addresses to list of TCP ports
Returns:
Dict mapping IP addresses to list of service info dicts
"""
if not ip_ports:
return {}
all_services = {}
for ip, ports in ip_ports.items():
if not ports:
all_services[ip] = []
continue
# Build port list string
port_list = ','.join(map(str, sorted(ports)))
print(f" Scanning {ip} ports {port_list}...", flush=True)
# Create temporary output file for XML
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.xml') as f:
xml_output = f.name
try:
# Run nmap with service detection
cmd = [
'nmap',
'-sV', # Service version detection
'--version-intensity', '5', # Balanced speed/accuracy
'-p', port_list,
'-oX', xml_output, # XML output
'--host-timeout', '5m', # Timeout per host
ip
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode != 0:
print(f" Nmap warning for {ip}: {result.stderr}", file=sys.stderr, flush=True)
# Parse XML output
services = self._parse_nmap_xml(xml_output)
all_services[ip] = services
except subprocess.TimeoutExpired:
print(f" Nmap timeout for {ip}, skipping service detection", file=sys.stderr, flush=True)
all_services[ip] = []
except Exception as e:
print(f" Nmap error for {ip}: {e}", file=sys.stderr, flush=True)
all_services[ip] = []
finally:
Path(xml_output).unlink(missing_ok=True)
return all_services
def _parse_nmap_xml(self, xml_file: str) -> List[Dict]:
"""
Parse nmap XML output to extract service information
Args:
xml_file: Path to nmap XML output file
Returns:
List of service info dictionaries
"""
services = []
try:
tree = ET.parse(xml_file)
root = tree.getroot()
# Find all ports
for port_elem in root.findall('.//port'):
port_id = port_elem.get('portid')
protocol = port_elem.get('protocol', 'tcp')
# Get state
state_elem = port_elem.find('state')
if state_elem is None or state_elem.get('state') != 'open':
continue
# Get service info
service_elem = port_elem.find('service')
if service_elem is not None:
service_info = {
'port': int(port_id),
'protocol': protocol,
'service': service_elem.get('name', 'unknown'),
'product': service_elem.get('product', ''),
'version': service_elem.get('version', ''),
'extrainfo': service_elem.get('extrainfo', ''),
'ostype': service_elem.get('ostype', '')
}
# Clean up empty fields
service_info = {k: v for k, v in service_info.items() if v}
services.append(service_info)
else:
# Port is open but no service info
services.append({
'port': int(port_id),
'protocol': protocol,
'service': 'unknown'
})
except Exception as e:
print(f" Error parsing nmap XML: {e}", file=sys.stderr, flush=True)
return services
def _is_likely_web_service(self, service: Dict) -> bool:
"""
Check if a service is likely HTTP/HTTPS based on nmap detection or common web ports
Args:
service: Service dictionary from nmap results
Returns:
True if service appears to be web-related
"""
# Check service name
web_services = ['http', 'https', 'ssl', 'http-proxy', 'https-alt',
'http-alt', 'ssl/http', 'ssl/https']
service_name = service.get('service', '').lower()
if service_name in web_services:
return True
# Check common non-standard web ports
web_ports = [80, 443, 8000, 8006, 8008, 8080, 8081, 8443, 8888, 9443]
port = service.get('port')
return port in web_ports
def _detect_http_https(self, ip: str, port: int, timeout: int = 5) -> str:
"""
Detect if a port is HTTP or HTTPS
Args:
ip: IP address
port: Port number
timeout: Connection timeout in seconds
Returns:
'http', 'https', or 'unknown'
"""
import socket
import ssl as ssl_module
# Try HTTPS first
try:
context = ssl_module.create_default_context()
context.check_hostname = False
context.verify_mode = ssl_module.CERT_NONE
with socket.create_connection((ip, port), timeout=timeout) as sock:
with context.wrap_socket(sock, server_hostname=ip) as ssock:
return 'https'
except ssl_module.SSLError:
# Not HTTPS, try HTTP
pass
except (socket.timeout, socket.error, ConnectionRefusedError):
return 'unknown'
# Try HTTP
try:
with socket.create_connection((ip, port), timeout=timeout) as sock:
sock.send(b'HEAD / HTTP/1.0\r\n\r\n')
response = sock.recv(1024)
if b'HTTP' in response:
return 'http'
except (socket.timeout, socket.error, ConnectionRefusedError):
pass
return 'unknown'
def _analyze_ssl_tls(self, ip: str, port: int) -> Dict[str, Any]:
"""
Analyze SSL/TLS configuration including certificate and supported versions
Args:
ip: IP address
port: Port number
Returns:
Dictionary with certificate info and TLS version support
"""
from sslyze import (
Scanner,
ServerScanRequest,
ServerNetworkLocation,
ScanCommand,
ScanCommandAttemptStatusEnum,
ServerScanStatusEnum
)
from cryptography import x509
from datetime import datetime
result = {
'certificate': {},
'tls_versions': {},
'errors': []
}
try:
# Create server location
server_location = ServerNetworkLocation(
hostname=ip,
port=port
)
# Create scan request with all TLS version scans
scan_request = ServerScanRequest(
server_location=server_location,
scan_commands={
ScanCommand.CERTIFICATE_INFO,
ScanCommand.SSL_2_0_CIPHER_SUITES,
ScanCommand.SSL_3_0_CIPHER_SUITES,
ScanCommand.TLS_1_0_CIPHER_SUITES,
ScanCommand.TLS_1_1_CIPHER_SUITES,
ScanCommand.TLS_1_2_CIPHER_SUITES,
ScanCommand.TLS_1_3_CIPHER_SUITES,
}
)
# Run scan
scanner = Scanner()
scanner.queue_scans([scan_request])
# Process results
for scan_result in scanner.get_results():
if scan_result.scan_status != ServerScanStatusEnum.COMPLETED:
result['errors'].append('Connection failed')
return result
server_scan_result = scan_result.scan_result
# Extract certificate information
cert_attempt = getattr(server_scan_result, 'certificate_info', None)
if cert_attempt and cert_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED:
cert_result = cert_attempt.result
if cert_result.certificate_deployments:
deployment = cert_result.certificate_deployments[0]
leaf_cert = deployment.received_certificate_chain[0]
# Calculate days until expiry
not_after = leaf_cert.not_valid_after_utc
days_until_expiry = (not_after - datetime.now(not_after.tzinfo)).days
# Extract SANs
sans = []
try:
san_ext = leaf_cert.extensions.get_extension_for_class(
x509.SubjectAlternativeName
)
sans = [name.value for name in san_ext.value]
except x509.ExtensionNotFound:
pass
result['certificate'] = {
'subject': leaf_cert.subject.rfc4514_string(),
'issuer': leaf_cert.issuer.rfc4514_string(),
'serial_number': str(leaf_cert.serial_number),
'not_valid_before': leaf_cert.not_valid_before_utc.isoformat(),
'not_valid_after': leaf_cert.not_valid_after_utc.isoformat(),
'days_until_expiry': days_until_expiry,
'sans': sans
}
# Test TLS versions
tls_attributes = {
'TLS 1.0': 'tls_1_0_cipher_suites',
'TLS 1.1': 'tls_1_1_cipher_suites',
'TLS 1.2': 'tls_1_2_cipher_suites',
'TLS 1.3': 'tls_1_3_cipher_suites'
}
for version_name, attr_name in tls_attributes.items():
tls_attempt = getattr(server_scan_result, attr_name, None)
if tls_attempt and tls_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED:
tls_result = tls_attempt.result
supported = len(tls_result.accepted_cipher_suites) > 0
cipher_suites = [
suite.cipher_suite.name
for suite in tls_result.accepted_cipher_suites
]
result['tls_versions'][version_name] = {
'supported': supported,
'cipher_suites': cipher_suites
}
else:
result['tls_versions'][version_name] = {
'supported': False,
'cipher_suites': []
}
except Exception as e:
result['errors'].append(str(e))
return result
def _run_http_analysis(self, ip_services: Dict[str, List[Dict]]) -> Dict[str, Dict[int, Dict]]:
"""
Analyze HTTP/HTTPS services and SSL/TLS configuration
Args:
ip_services: Dict mapping IP addresses to their service lists
Returns:
Dict mapping IPs to port-specific HTTP analysis results
"""
if not ip_services:
return {}
all_results = {}
for ip, services in ip_services.items():
ip_results = {}
for service in services:
if not self._is_likely_web_service(service):
continue
port = service['port']
print(f" Analyzing {ip}:{port}...", flush=True)
# Detect HTTP vs HTTPS
protocol = self._detect_http_https(ip, port, timeout=5)
if protocol == 'unknown':
continue
result = {'protocol': protocol}
# Capture screenshot if screenshot capture is enabled
if self.screenshot_capture:
try:
screenshot_path = self.screenshot_capture.capture(ip, port, protocol)
if screenshot_path:
result['screenshot'] = screenshot_path
except Exception as e:
print(f" Screenshot capture error for {ip}:{port}: {e}",
file=sys.stderr, flush=True)
# If HTTPS, analyze SSL/TLS
if protocol == 'https':
try:
ssl_info = self._analyze_ssl_tls(ip, port)
# Only include ssl_tls if we got meaningful data
if ssl_info.get('certificate') or ssl_info.get('tls_versions'):
result['ssl_tls'] = ssl_info
elif ssl_info.get('errors'):
# Log errors even if we don't include ssl_tls in output
print(f" SSL/TLS analysis failed for {ip}:{port}: {ssl_info['errors']}",
file=sys.stderr, flush=True)
except Exception as e:
print(f" SSL/TLS analysis error for {ip}:{port}: {e}",
file=sys.stderr, flush=True)
ip_results[port] = result
if ip_results:
all_results[ip] = ip_results
return all_results
def scan(self) -> Dict[str, Any]:
"""
Perform complete scan based on configuration
Returns:
Dictionary containing scan results
"""
print(f"Starting scan: {self.config['title']}", flush=True)
print(f"Config: {self.config_path}", flush=True)
# Record start time
start_time = time.time()
scan_timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# Initialize screenshot capture
self.screenshot_capture = ScreenshotCapture(
output_dir=str(self.output_dir),
scan_timestamp=scan_timestamp,
timeout=15
)
# Collect all unique IPs
all_ips = set()
ip_to_site = {}
ip_expected = {}
for site in self.config['sites']:
site_name = site['name']
for ip_config in site['ips']:
ip = ip_config['address']
all_ips.add(ip)
ip_to_site[ip] = site_name
ip_expected[ip] = ip_config.get('expected', {})
all_ips = sorted(list(all_ips))
print(f"Total IPs to scan: {len(all_ips)}", flush=True)
# Perform ping scan
print(f"\n[1/5] Performing ping scan on {len(all_ips)} IPs...", flush=True)
ping_results = self._run_ping_scan(all_ips)
# Perform TCP scan (all ports)
print(f"\n[2/5] Performing TCP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp')
# Perform UDP scan (all ports)
print(f"\n[3/5] Performing UDP scan on {len(all_ips)} IPs (ports 0-65535)...", flush=True)
udp_results = self._run_masscan(all_ips, '0-65535', 'udp')
# Organize results by IP
results_by_ip = {}
for ip in all_ips:
results_by_ip[ip] = {
'site': ip_to_site[ip],
'expected': ip_expected[ip],
'actual': {
'ping': ping_results.get(ip, False),
'tcp_ports': [],
'udp_ports': [],
'services': []
}
}
# Add TCP ports
for result in tcp_results:
ip = result.get('ip')
port = result.get('ports', [{}])[0].get('port')
if ip in results_by_ip and port:
results_by_ip[ip]['actual']['tcp_ports'].append(port)
# Add UDP ports
for result in udp_results:
ip = result.get('ip')
port = result.get('ports', [{}])[0].get('port')
if ip in results_by_ip and port:
results_by_ip[ip]['actual']['udp_ports'].append(port)
# Sort ports
for ip in results_by_ip:
results_by_ip[ip]['actual']['tcp_ports'].sort()
results_by_ip[ip]['actual']['udp_ports'].sort()
# Perform service detection on TCP ports
print(f"\n[4/5] Performing service detection on discovered TCP ports...", flush=True)
ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips}
service_results = self._run_nmap_service_detection(ip_ports)
# Add service information to results
for ip, services in service_results.items():
if ip in results_by_ip:
results_by_ip[ip]['actual']['services'] = services
# Perform HTTP/HTTPS analysis on web services
print(f"\n[5/5] Analyzing HTTP/HTTPS services and SSL/TLS configuration...", flush=True)
http_results = self._run_http_analysis(service_results)
# Merge HTTP analysis into service results
for ip, port_results in http_results.items():
if ip in results_by_ip:
for service in results_by_ip[ip]['actual']['services']:
port = service['port']
if port in port_results:
service['http_info'] = port_results[port]
# Calculate scan duration
end_time = time.time()
scan_duration = round(end_time - start_time, 2)
# Build final report
report = {
'title': self.config['title'],
'scan_time': datetime.utcnow().isoformat() + 'Z',
'scan_duration': scan_duration,
'config_file': str(self.config_path),
'sites': []
}
for site in self.config['sites']:
site_result = {
'name': site['name'],
'ips': []
}
for ip_config in site['ips']:
ip = ip_config['address']
site_result['ips'].append({
'address': ip,
'expected': ip_expected[ip],
'actual': results_by_ip[ip]['actual']
})
report['sites'].append(site_result)
# Clean up screenshot capture browser
if self.screenshot_capture:
self.screenshot_capture._close_browser()
return report, scan_timestamp
def save_report(self, report: Dict[str, Any], scan_timestamp: str) -> Path:
"""Save scan report to JSON file using provided timestamp"""
output_file = self.output_dir / f"scan_report_{scan_timestamp}.json"
with open(output_file, 'w') as f:
json.dump(report, f, indent=2)
print(f"\nReport saved to: {output_file}", flush=True)
return output_file
def generate_outputs(self, report: Dict[str, Any], scan_timestamp: str) -> Dict[str, Path]:
"""
Generate all output formats: JSON, HTML report, and ZIP archive
Args:
report: Scan report dictionary
scan_timestamp: Timestamp string in format YYYYMMDD_HHMMSS
Returns:
Dictionary with paths to generated files: {'json': Path, 'html': Path, 'zip': Path}
"""
output_paths = {}
# Step 1: Save JSON report
print("\n" + "="*60, flush=True)
print("Generating outputs...", flush=True)
print("="*60, flush=True)
json_path = self.save_report(report, scan_timestamp)
output_paths['json'] = json_path
# Step 2: Generate HTML report
html_path = self.output_dir / f"scan_report_{scan_timestamp}.html"
try:
print(f"\nGenerating HTML report...", flush=True)
# Auto-detect template directory relative to this script
template_dir = Path(__file__).parent.parent / 'templates'
# Create HTML report generator
generator = HTMLReportGenerator(
json_report_path=str(json_path),
template_dir=str(template_dir)
)
# Generate report
html_result = generator.generate_report(output_path=str(html_path))
output_paths['html'] = Path(html_result)
print(f"HTML report saved to: {html_path}", flush=True)
except Exception as e:
print(f"Warning: HTML report generation failed: {e}", file=sys.stderr, flush=True)
print(f"Continuing with JSON output only...", file=sys.stderr, flush=True)
# Don't add html_path to output_paths if it failed
# Step 3: Create ZIP archive
zip_path = self.output_dir / f"scan_report_{scan_timestamp}.zip"
try:
print(f"\nCreating ZIP archive...", flush=True)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Add JSON report
zipf.write(json_path, json_path.name)
# Add HTML report if it was generated
if 'html' in output_paths and html_path.exists():
zipf.write(html_path, html_path.name)
# Add screenshots directory if it exists
screenshot_dir = self.output_dir / f"scan_report_{scan_timestamp}_screenshots"
if screenshot_dir.exists() and screenshot_dir.is_dir():
# Add all files in screenshot directory
for screenshot_file in screenshot_dir.iterdir():
if screenshot_file.is_file():
# Preserve directory structure in ZIP
arcname = f"{screenshot_dir.name}/{screenshot_file.name}"
zipf.write(screenshot_file, arcname)
output_paths['zip'] = zip_path
print(f"ZIP archive saved to: {zip_path}", flush=True)
except Exception as e:
print(f"Warning: ZIP archive creation failed: {e}", file=sys.stderr, flush=True)
# Don't add zip_path to output_paths if it failed
return output_paths
def main():
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stderr)]
)
parser = argparse.ArgumentParser(
description='SneakyScanner - Masscan-based network scanner'
)
parser.add_argument(
'config',
help='Path to YAML configuration file'
)
parser.add_argument(
'-o', '--output-dir',
default='/app/output',
help='Output directory for scan results (default: /app/output)'
)
args = parser.parse_args()
try:
scanner = SneakyScanner(args.config, args.output_dir)
report, scan_timestamp = scanner.scan()
output_paths = scanner.generate_outputs(report, scan_timestamp)
print("\n" + "="*60, flush=True)
print("Scan completed successfully!", flush=True)
print("="*60, flush=True)
print(f" JSON Report: {output_paths.get('json', 'N/A')}", flush=True)
print(f" HTML Report: {output_paths.get('html', 'N/A')}", flush=True)
print(f" ZIP Archive: {output_paths.get('zip', 'N/A')}", flush=True)
print("="*60, flush=True)
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr, flush=True)
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,201 @@
"""
Screenshot capture module for SneakyScanner.
Uses Playwright with Chromium to capture screenshots of discovered web services.
"""
import os
import logging
from pathlib import Path
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
class ScreenshotCapture:
"""
Handles webpage screenshot capture for web services discovered during scanning.
Uses Playwright with Chromium in headless mode to capture viewport screenshots
of HTTP and HTTPS services. Handles SSL certificate errors gracefully.
"""
def __init__(self, output_dir, scan_timestamp, timeout=15, viewport=None):
"""
Initialize the screenshot capture handler.
Args:
output_dir (str): Base output directory for scan reports
scan_timestamp (str): Timestamp string for this scan (format: YYYYMMDD_HHMMSS)
timeout (int): Timeout in seconds for page load and screenshot (default: 15)
viewport (dict): Viewport size dict with 'width' and 'height' keys
(default: {'width': 1280, 'height': 720})
"""
self.output_dir = output_dir
self.scan_timestamp = scan_timestamp
self.timeout = timeout * 1000 # Convert to milliseconds for Playwright
self.viewport = viewport or {'width': 1280, 'height': 720}
self.playwright = None
self.browser = None
self.screenshot_dir = None
# Set up logging
self.logger = logging.getLogger('SneakyScanner.Screenshot')
def _get_screenshot_dir(self):
"""
Create and return the screenshots subdirectory for this scan.
Returns:
Path: Path object for the screenshots directory
"""
if self.screenshot_dir is None:
dir_name = f"scan_report_{self.scan_timestamp}_screenshots"
self.screenshot_dir = Path(self.output_dir) / dir_name
self.screenshot_dir.mkdir(parents=True, exist_ok=True)
self.logger.info(f"Created screenshot directory: {self.screenshot_dir}")
return self.screenshot_dir
def _generate_filename(self, ip, port):
"""
Generate a filename for the screenshot.
Args:
ip (str): IP address of the service
port (int): Port number of the service
Returns:
str: Filename in format: {ip}_{port}.png
"""
# Replace dots in IP with underscores for filesystem compatibility
safe_ip = ip.replace('.', '_')
return f"{safe_ip}_{port}.png"
def _launch_browser(self):
"""
Launch Playwright and Chromium browser in headless mode.
Returns:
bool: True if browser launched successfully, False otherwise
"""
if self.browser is not None:
return True # Already launched
try:
self.logger.info("Launching Chromium browser...")
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(
headless=True,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
]
)
self.logger.info("Chromium browser launched successfully")
return True
except Exception as e:
self.logger.error(f"Failed to launch browser: {e}")
return False
def _close_browser(self):
"""
Close the browser and cleanup Playwright resources.
"""
if self.browser:
try:
self.browser.close()
self.logger.info("Browser closed")
except Exception as e:
self.logger.warning(f"Error closing browser: {e}")
finally:
self.browser = None
if self.playwright:
try:
self.playwright.stop()
except Exception as e:
self.logger.warning(f"Error stopping playwright: {e}")
finally:
self.playwright = None
def capture(self, ip, port, protocol):
"""
Capture a screenshot of a web service.
Args:
ip (str): IP address of the service
port (int): Port number of the service
protocol (str): Protocol to use ('http' or 'https')
Returns:
str: Relative path to the screenshot file, or None if capture failed
"""
# Validate protocol
if protocol not in ['http', 'https']:
self.logger.warning(f"Invalid protocol '{protocol}' for {ip}:{port}")
return None
# Launch browser if not already running
if not self._launch_browser():
return None
# Build URL
url = f"{protocol}://{ip}:{port}"
# Generate screenshot filename
filename = self._generate_filename(ip, port)
screenshot_dir = self._get_screenshot_dir()
screenshot_path = screenshot_dir / filename
try:
self.logger.info(f"Capturing screenshot: {url}")
# Create new browser context with viewport and SSL settings
context = self.browser.new_context(
viewport=self.viewport,
ignore_https_errors=True, # Handle self-signed certs
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
)
# Create new page
page = context.new_page()
# Set default timeout
page.set_default_timeout(self.timeout)
# Navigate to URL
page.goto(url, wait_until='networkidle', timeout=self.timeout)
# Take screenshot (viewport only)
page.screenshot(path=str(screenshot_path), type='png')
# Close page and context
page.close()
context.close()
self.logger.info(f"Screenshot saved: {screenshot_path}")
# Return relative path (relative to output directory)
relative_path = f"{screenshot_dir.name}/{filename}"
return relative_path
except PlaywrightTimeout:
self.logger.warning(f"Timeout capturing screenshot for {url}")
return None
except Exception as e:
self.logger.warning(f"Failed to capture screenshot for {url}: {e}")
return None
def __enter__(self):
"""Context manager entry."""
self._launch_browser()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - cleanup browser resources."""
self._close_browser()
return False # Don't suppress exceptions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,949 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SneakyScanner Report - {{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.header {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
border: 1px solid #475569;
}
.header h1 {
font-size: 2rem;
margin-bottom: 10px;
color: #60a5fa;
}
.header-meta {
display: flex;
gap: 30px;
color: #94a3b8;
font-size: 0.95rem;
}
.header-meta span {
display: flex;
align-items: center;
gap: 8px;
}
/* Summary Dashboard */
.dashboard {
background-color: #1e293b;
padding: 25px;
border-radius: 12px;
margin-bottom: 30px;
border: 1px solid #334155;
}
.dashboard h2 {
font-size: 1.5rem;
margin-bottom: 20px;
color: #60a5fa;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.dashboard-card {
background-color: #0f172a;
padding: 20px;
border-radius: 8px;
border: 1px solid #334155;
}
.dashboard-card h3 {
font-size: 1rem;
color: #94a3b8;
margin-bottom: 15px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
}
.stat-label {
color: #94a3b8;
}
.stat-value {
color: #e2e8f0;
font-weight: 600;
}
.alert-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.alert-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
background-color: #1e293b;
border-radius: 6px;
border-left: 3px solid;
}
.alert-item.critical {
border-color: #ef4444;
}
.alert-item.warning {
border-color: #f59e0b;
}
.alert-item.info {
border-color: #3b82f6;
}
/* Badges */
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge.expected {
background-color: #065f46;
color: #6ee7b7;
}
.badge.unexpected {
background-color: #7f1d1d;
color: #fca5a5;
}
.badge.missing {
background-color: #78350f;
color: #fcd34d;
}
.badge.critical {
background-color: #7f1d1d;
color: #fca5a5;
}
.badge.warning {
background-color: #78350f;
color: #fcd34d;
}
.badge.good {
background-color: #065f46;
color: #6ee7b7;
}
.badge.info {
background-color: #1e3a8a;
color: #93c5fd;
}
.badge-count {
background-color: #334155;
color: #e2e8f0;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 600;
}
/* Site Section */
.site-section {
background-color: #1e293b;
padding: 25px;
border-radius: 12px;
margin-bottom: 25px;
border: 1px solid #334155;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #334155;
}
.site-header h2 {
font-size: 1.75rem;
color: #60a5fa;
}
.site-stats {
display: flex;
gap: 15px;
color: #94a3b8;
font-size: 0.9rem;
}
/* IP Section */
.ip-section {
background-color: #0f172a;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #334155;
}
.ip-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.ip-header h3 {
font-size: 1.25rem;
color: #e2e8f0;
font-family: 'Courier New', monospace;
}
.ip-badges {
display: flex;
gap: 8px;
}
/* Service Table */
.service-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
background-color: #1e293b;
border-radius: 6px;
overflow: hidden;
}
.service-table thead {
background-color: #334155;
}
.service-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
}
.service-table td {
padding: 12px;
border-top: 1px solid #334155;
}
.service-table tbody tr {
cursor: pointer;
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.service-table tbody tr:hover {
background-color: #334155;
border-left-color: #60a5fa;
}
.service-row-clickable {
position: relative;
}
.service-row-clickable::after {
content: '▼';
position: absolute;
right: 12px;
color: #64748b;
font-size: 0.7rem;
transition: transform 0.2s;
}
.service-row-clickable.expanded::after {
transform: rotate(-180deg);
}
/* Service Details Card */
.service-details {
display: none;
background-color: #0f172a;
padding: 20px;
margin: 10px 0;
border-radius: 6px;
border: 1px solid #334155;
border-left: 3px solid #60a5fa;
}
.service-details.show {
display: block;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
color: #94a3b8;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-value {
color: #e2e8f0;
font-weight: 500;
font-family: 'Courier New', monospace;
}
/* SSL/TLS Section */
.ssl-section {
background-color: #1e293b;
padding: 15px;
border-radius: 6px;
margin-top: 15px;
border: 1px solid #334155;
}
.ssl-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 5px 0;
}
.ssl-header h4 {
color: #60a5fa;
font-size: 1rem;
}
.ssl-toggle {
color: #64748b;
font-size: 0.8rem;
}
.ssl-content {
display: none;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #334155;
}
.ssl-content.show {
display: block;
}
.cert-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.tls-versions {
margin-top: 15px;
}
.tls-version-item {
background-color: #0f172a;
padding: 12px;
border-radius: 4px;
margin-bottom: 10px;
border-left: 3px solid;
}
.tls-version-item.supported {
border-color: #10b981;
}
.tls-version-item.unsupported {
border-color: #64748b;
}
.tls-version-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.cipher-list {
color: #94a3b8;
font-size: 0.85rem;
font-family: 'Courier New', monospace;
margin-left: 15px;
}
.cipher-list li {
margin: 4px 0;
}
/* Screenshot Link */
.screenshot-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #60a5fa;
text-decoration: none;
font-size: 0.9rem;
margin-top: 10px;
transition: color 0.2s;
}
.screenshot-link:hover {
color: #93c5fd;
text-decoration: underline;
}
/* Utilities */
.mono {
font-family: 'Courier New', monospace;
}
.text-muted {
color: #94a3b8;
}
.text-success {
color: #10b981;
}
.text-warning {
color: #f59e0b;
}
.text-danger {
color: #ef4444;
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1>{{ title }}</h1>
<div class="header-meta">
<span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span>
<span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span>
{% if config_file %}
<span>📄 <strong>Config:</strong> {{ config_file }}</span>
{% endif %}
</div>
</div>
<!-- Summary Dashboard -->
<div class="dashboard">
<h2>Scan Summary</h2>
<div class="dashboard-grid">
<!-- Statistics Card -->
<div class="dashboard-card">
<h3>Scan Statistics</h3>
<div class="stat-grid">
<div class="stat-item">
<span class="stat-label">Total IPs Scanned</span>
<span class="stat-value">{{ summary_stats.total_ips }}</span>
</div>
<div class="stat-item">
<span class="stat-label">TCP Ports Found</span>
<span class="stat-value">{{ summary_stats.tcp_ports }}</span>
</div>
<div class="stat-item">
<span class="stat-label">UDP Ports Found</span>
<span class="stat-value">{{ summary_stats.udp_ports }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Services Identified</span>
<span class="stat-value">{{ summary_stats.services }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Web Services</span>
<span class="stat-value">{{ summary_stats.web_services }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Screenshots Captured</span>
<span class="stat-value">{{ summary_stats.screenshots }}</span>
</div>
</div>
</div>
<!-- Drift Alerts Card -->
<div class="dashboard-card">
<h3>Drift Alerts</h3>
<div class="alert-grid">
{% if drift_alerts.unexpected_tcp > 0 %}
<div class="alert-item warning">
<span>Unexpected TCP Ports</span>
<span class="badge-count">{{ drift_alerts.unexpected_tcp }}</span>
</div>
{% endif %}
{% if drift_alerts.unexpected_udp > 0 %}
<div class="alert-item warning">
<span>Unexpected UDP Ports</span>
<span class="badge-count">{{ drift_alerts.unexpected_udp }}</span>
</div>
{% endif %}
{% if drift_alerts.missing_tcp > 0 or drift_alerts.missing_udp > 0 %}
<div class="alert-item critical">
<span>Missing Expected Services</span>
<span class="badge-count">{{ drift_alerts.missing_tcp + drift_alerts.missing_udp }}</span>
</div>
{% endif %}
{% if drift_alerts.new_services > 0 %}
<div class="alert-item info">
<span>New Services Detected</span>
<span class="badge-count">{{ drift_alerts.new_services }}</span>
</div>
{% endif %}
{% if drift_alerts.unexpected_tcp == 0 and drift_alerts.unexpected_udp == 0 and drift_alerts.missing_tcp == 0 and drift_alerts.missing_udp == 0 %}
<div class="alert-item info">
<span>No drift detected - all services match expectations</span>
<span class="badge good"></span>
</div>
{% endif %}
</div>
</div>
<!-- Security Warnings Card -->
<div class="dashboard-card">
<h3>Security Warnings</h3>
<div class="alert-grid">
{% if security_warnings.expiring_certs > 0 %}
<div class="alert-item critical">
<span>Certificates Expiring Soon (&lt;30 days)</span>
<span class="badge-count">{{ security_warnings.expiring_certs }}</span>
</div>
{% endif %}
{% if security_warnings.weak_tls > 0 %}
<div class="alert-item warning">
<span>Weak TLS Versions (1.0/1.1)</span>
<span class="badge-count">{{ security_warnings.weak_tls }}</span>
</div>
{% endif %}
{% if security_warnings.self_signed > 0 %}
<div class="alert-item warning">
<span>Self-Signed Certificates</span>
<span class="badge-count">{{ security_warnings.self_signed }}</span>
</div>
{% endif %}
{% if security_warnings.high_ports > 0 %}
<div class="alert-item info">
<span>High Port Services (&gt;10000)</span>
<span class="badge-count">{{ security_warnings.high_ports }}</span>
</div>
{% endif %}
{% if security_warnings.expiring_certs == 0 and security_warnings.weak_tls == 0 and security_warnings.self_signed == 0 %}
<div class="alert-item info">
<span>No critical security warnings detected</span>
<span class="badge good"></span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Sites -->
{% for site in sites %}
<div class="site-section">
<div class="site-header">
<h2>{{ site.name }}</h2>
<div class="site-stats">
<span>{{ site.ips | length }} IP{{ 's' if site.ips | length != 1 else '' }}</span>
</div>
</div>
<!-- IPs -->
{% for ip_data in site.ips %}
{% set expected = ip_data.expected %}
{% set actual = ip_data.actual %}
{% set expected_tcp = expected.tcp_ports | default([]) | list %}
{% set actual_tcp = actual.tcp_ports | default([]) | list %}
{% set expected_udp = expected.udp_ports | default([]) | list %}
{% set actual_udp = actual.udp_ports | default([]) | list %}
{% set unexpected_tcp = actual_tcp | reject('in', expected_tcp) | list %}
{% set unexpected_udp = actual_udp | reject('in', expected_udp) | list %}
{% set missing_tcp = expected_tcp | reject('in', actual_tcp) | list %}
{% set missing_udp = expected_udp | reject('in', actual_udp) | list %}
<div class="ip-section">
<div class="ip-header">
<h3>{{ ip_data.address }}</h3>
<div class="ip-badges">
{% if expected.ping %}
{% if actual.ping %}
<span class="badge expected">Ping: Expected</span>
{% else %}
<span class="badge missing">Ping: Missing</span>
{% endif %}
{% endif %}
{% if (unexpected_tcp | length) > 0 or (unexpected_udp | length) > 0 %}
<span class="badge warning">{{ (unexpected_tcp | length) + (unexpected_udp | length) }} Unexpected Port{{ 's' if ((unexpected_tcp | length) + (unexpected_udp | length)) > 1 else '' }}</span>
{% elif (missing_tcp | length) > 0 or (missing_udp | length) > 0 %}
<span class="badge critical">{{ (missing_tcp | length) + (missing_udp | length) }} Missing Service{{ 's' if ((missing_tcp | length) + (missing_udp | length)) > 1 else '' }}</span>
{% else %}
<span class="badge good">All Ports Expected</span>
{% endif %}
</div>
</div>
<table class="service-table">
<thead>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>Service</th>
<th>Product</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for service in actual.services | default([]) %}
{% set service_id = 'service_' ~ loop.index ~ '_' ~ ip_data.address | replace('.', '_') %}
{% set is_expected = service.port in expected_tcp or service.port in expected_udp %}
<tr class="service-row-clickable" onclick="toggleDetails('{{ service_id }}')">
<td class="mono">{{ service.port }}</td>
<td>{{ service.protocol | upper }}</td>
<td>{{ service.service | default('unknown') }}</td>
<td>{{ service.product | default('') }} {% if service.version %}{{ service.version }}{% endif %}</td>
<td>
{% if is_expected %}
<span class="badge expected">Expected</span>
{% else %}
<span class="badge unexpected">Unexpected</span>
{% endif %}
</td>
</tr>
<tr>
<td colspan="5">
<div id="{{ service_id }}" class="service-details">
<div class="details-grid">
{% if service.product %}
<div class="detail-item">
<span class="detail-label">Product</span>
<span class="detail-value">{{ service.product }}</span>
</div>
{% endif %}
{% if service.version %}
<div class="detail-item">
<span class="detail-label">Version</span>
<span class="detail-value">{{ service.version }}</span>
</div>
{% endif %}
{% if service.extrainfo %}
<div class="detail-item">
<span class="detail-label">Extra Info</span>
<span class="detail-value">{{ service.extrainfo }}</span>
</div>
{% endif %}
{% if service.ostype %}
<div class="detail-item">
<span class="detail-label">OS Type</span>
<span class="detail-value">{{ service.ostype }}</span>
</div>
{% endif %}
{% if service.http_info %}
<div class="detail-item">
<span class="detail-label">Protocol</span>
<span class="detail-value">{{ service.http_info.protocol | upper }}</span>
</div>
{% endif %}
{% if not is_expected %}
<div class="detail-item">
<span class="detail-label">⚠️ Status</span>
<span class="detail-value text-warning">Not in expected ports list</span>
</div>
{% endif %}
</div>
{% if service.http_info and service.http_info.screenshot %}
<a href="{{ service.http_info.screenshot }}" class="screenshot-link" target="_blank">
🖼️ View Screenshot
</a>
{% endif %}
{% if service.http_info and service.http_info.ssl_tls %}
{% set ssl_id = 'ssl_' ~ loop.index ~ '_' ~ ip_data.address | replace('.', '_') %}
{% set ssl = service.http_info.ssl_tls %}
{% set cert = ssl.certificate %}
<div class="ssl-section">
<div class="ssl-header" onclick="toggleSSL('{{ ssl_id }}')">
<h4>🔒 SSL/TLS Details
{% if cert.days_until_expiry is defined and cert.days_until_expiry < 30 %}
<span class="badge critical" style="margin-left: 10px;">Certificate Expiring Soon</span>
{% elif cert.issuer == cert.subject %}
<span class="badge warning" style="margin-left: 10px;">Self-Signed Certificate</span>
{% endif %}
</h4>
<span class="ssl-toggle">Click to expand ▼</span>
</div>
<div id="{{ ssl_id }}" class="ssl-content">
<h5 style="color: #94a3b8; margin-bottom: 10px;">Certificate Information</h5>
<div class="cert-grid">
{% if cert.subject %}
<div class="detail-item">
<span class="detail-label">Subject</span>
<span class="detail-value">{{ cert.subject }}</span>
</div>
{% endif %}
{% if cert.issuer %}
<div class="detail-item">
<span class="detail-label">Issuer</span>
<span class="detail-value {% if cert.issuer == cert.subject %}text-warning{% endif %}">
{{ cert.issuer }}{% if cert.issuer == cert.subject %} (Self-Signed){% endif %}
</span>
</div>
{% endif %}
{% if cert.not_valid_before %}
<div class="detail-item">
<span class="detail-label">Valid From</span>
<span class="detail-value">{{ cert.not_valid_before | format_date }}</span>
</div>
{% endif %}
{% if cert.not_valid_after %}
<div class="detail-item">
<span class="detail-label">Valid Until</span>
<span class="detail-value {% if cert.days_until_expiry is defined and cert.days_until_expiry < 30 %}text-danger{% else %}text-success{% endif %}">
{{ cert.not_valid_after | format_date }}
</span>
</div>
{% endif %}
{% if cert.days_until_expiry is defined %}
<div class="detail-item">
<span class="detail-label">Days Until Expiry</span>
<span class="detail-value {% if cert.days_until_expiry < 30 %}text-danger{% else %}text-success{% endif %}">
{{ cert.days_until_expiry }} days{% if cert.days_until_expiry < 30 %} {% endif %}
</span>
</div>
{% endif %}
{% if cert.serial_number %}
<div class="detail-item">
<span class="detail-label">Serial Number</span>
<span class="detail-value">{{ cert.serial_number }}</span>
</div>
{% endif %}
</div>
{% if cert.sans %}
<div class="detail-item" style="margin-bottom: 15px;">
<span class="detail-label">Subject Alternative Names (SANs)</span>
<span class="detail-value">{{ cert.sans | join(', ') }}</span>
</div>
{% endif %}
{% if ssl.tls_versions %}
<div class="tls-versions">
<h5 style="color: #94a3b8; margin-bottom: 10px;">TLS Version Support</h5>
{% for version_name in ['TLS 1.0', 'TLS 1.1', 'TLS 1.2', 'TLS 1.3'] %}
{% set tls_version = ssl.tls_versions.get(version_name, {}) %}
{% if tls_version.supported %}
<div class="tls-version-item supported">
<div class="tls-version-header">
<strong>{{ version_name }}</strong>
{% if version_name in ['TLS 1.0', 'TLS 1.1'] %}
<span class="badge warning">Supported (Weak) ⚠️</span>
{% else %}
<span class="badge good">Supported</span>
{% endif %}
</div>
{% if tls_version.cipher_suites %}
<ul class="cipher-list">
{% for cipher in tls_version.cipher_suites %}
<li>{{ cipher }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% elif tls_version.supported is defined %}
<div class="tls-version-item unsupported">
<div class="tls-version-header">
<strong>{{ version_name }}</strong>
<span class="badge info">Not Supported</span>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{# Show expected UDP ports (UDP ports found and expected, with no service details) #}
{% for port in actual_udp %}
{% if port in expected_udp %}
<tr class="service-row-clickable" onclick="toggleDetails('udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}')">
<td class="mono">{{ port }}</td>
<td>UDP</td>
<td colspan="2" class="text-muted">No service detection available</td>
<td><span class="badge expected">Expected</span></td>
</tr>
<tr>
<td colspan="5">
<div id="udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}" class="service-details">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Protocol</span>
<span class="detail-value">UDP</span>
</div>
<div class="detail-item">
<span class="detail-label">Note</span>
<span class="detail-value text-muted">Service detection not available for UDP ports</span>
</div>
</div>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
{# Show unexpected UDP ports (UDP ports found but not expected, with no service details) #}
{% for port in unexpected_udp %}
<tr class="service-row-clickable" onclick="toggleDetails('udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}')">
<td class="mono">{{ port }}</td>
<td>UDP</td>
<td colspan="2" class="text-muted">No service detection available</td>
<td><span class="badge unexpected">Unexpected</span></td>
</tr>
<tr>
<td colspan="5">
<div id="udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}" class="service-details">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">⚠️ Status</span>
<span class="detail-value text-warning">UDP port discovered but not in expected ports list. Service detection not available for UDP.</span>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
{# Show missing expected services #}
{% for port in missing_tcp %}
<tr style="background-color: rgba(127, 29, 29, 0.2);">
<td class="mono">{{ port }}</td>
<td>TCP</td>
<td colspan="2" class="text-danger">❌ Expected but not found</td>
<td><span class="badge missing">Missing</span></td>
</tr>
{% endfor %}
{% for port in missing_udp %}
<tr style="background-color: rgba(127, 29, 29, 0.2);">
<td class="mono">{{ port }}</td>
<td>UDP</td>
<td colspan="2" class="text-danger">❌ Expected but not found</td>
<td><span class="badge missing">Missing</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<script>
function toggleDetails(id) {
const details = document.getElementById(id);
const row = event.currentTarget;
if (details.classList.contains('show')) {
details.classList.remove('show');
row.classList.remove('expanded');
} else {
details.classList.add('show');
row.classList.add('expanded');
}
}
function toggleSSL(id) {
const sslContent = document.getElementById(id);
const header = event.currentTarget;
const toggle = header.querySelector('.ssl-toggle');
if (sslContent.classList.contains('show')) {
sslContent.classList.remove('show');
toggle.textContent = 'Click to expand ▼';
} else {
sslContent.classList.add('show');
toggle.textContent = 'Click to collapse ▲';
}
event.stopPropagation();
}
</script>
</body>
</html>

1
app/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test package for SneakyScanner."""

384
app/tests/conftest.py Normal file
View File

@@ -0,0 +1,384 @@
"""
Pytest configuration and fixtures for SneakyScanner tests.
"""
import os
import tempfile
from datetime import datetime
from pathlib import Path
import pytest
import yaml
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.app import create_app
from web.models import Base, Scan
from web.utils.settings import PasswordManager, SettingsManager
@pytest.fixture(scope='function')
def test_db():
"""
Create a temporary test database.
Yields a SQLAlchemy session for testing, then cleans up.
"""
# Create temporary database file
db_fd, db_path = tempfile.mkstemp(suffix='.db')
# Create engine and session
engine = create_engine(f'sqlite:///{db_path}', echo=False)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
# Cleanup
session.close()
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def sample_scan_report():
"""
Sample scan report matching the structure from scanner.py.
Returns a dictionary representing a typical scan output.
"""
return {
'title': 'Test Scan',
'scan_time': '2025-11-14T10:30:00Z',
'scan_duration': 125.5,
'config_file': '/app/configs/test.yaml',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '192.168.1.10',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53],
'services': []
},
'actual': {
'ping': True,
'tcp_ports': [22, 80, 443, 8080],
'udp_ports': [53],
'services': [
{
'port': 22,
'service': 'ssh',
'product': 'OpenSSH',
'version': '8.9p1',
'extrainfo': 'Ubuntu',
'ostype': 'Linux'
},
{
'port': 443,
'service': 'https',
'product': 'nginx',
'version': '1.24.0',
'extrainfo': '',
'ostype': '',
'http_info': {
'protocol': 'https',
'screenshot': 'screenshots/192_168_1_10_443.png',
'certificate': {
'subject': 'CN=example.com',
'issuer': 'CN=Let\'s Encrypt Authority',
'serial_number': '123456789',
'not_valid_before': '2025-01-01T00:00:00Z',
'not_valid_after': '2025-12-31T23:59:59Z',
'days_until_expiry': 365,
'sans': ['example.com', 'www.example.com'],
'is_self_signed': False,
'tls_versions': {
'TLS 1.2': {
'supported': True,
'cipher_suites': [
'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256'
]
},
'TLS 1.3': {
'supported': True,
'cipher_suites': [
'TLS_AES_256_GCM_SHA384',
'TLS_AES_128_GCM_SHA256'
]
}
}
}
}
},
{
'port': 80,
'service': 'http',
'product': 'nginx',
'version': '1.24.0',
'extrainfo': '',
'ostype': '',
'http_info': {
'protocol': 'http',
'screenshot': 'screenshots/192_168_1_10_80.png'
}
},
{
'port': 8080,
'service': 'http',
'product': 'Jetty',
'version': '9.4.48',
'extrainfo': '',
'ostype': ''
}
]
}
}
]
}
]
}
@pytest.fixture
def sample_config_file(tmp_path):
"""
Create a sample YAML config file for testing.
Args:
tmp_path: pytest temporary directory fixture
Returns:
Path to created config file
"""
config_data = {
'title': 'Test Scan',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '192.168.1.10',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53],
'services': ['ssh', 'http', 'https']
}
}
]
}
]
}
config_file = tmp_path / 'test_config.yaml'
with open(config_file, 'w') as f:
yaml.dump(config_data, f)
return str(config_file)
@pytest.fixture
def sample_invalid_config_file(tmp_path):
"""
Create an invalid config file for testing validation.
Returns:
Path to invalid config file
"""
config_file = tmp_path / 'invalid_config.yaml'
with open(config_file, 'w') as f:
f.write("invalid: yaml: content: [missing closing bracket")
return str(config_file)
@pytest.fixture(scope='function')
def app():
"""
Create Flask application for testing.
Returns:
Configured Flask app instance with test database
"""
# Create temporary database
db_fd, db_path = tempfile.mkstemp(suffix='.db')
# Create app with test config
test_config = {
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
'SECRET_KEY': 'test-secret-key'
}
app = create_app(test_config)
yield app
# Cleanup
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture(scope='function')
def client(app):
"""
Create Flask test client.
Args:
app: Flask application fixture
Returns:
Flask test client for making API requests
"""
return app.test_client()
@pytest.fixture(scope='function')
def db(app):
"""
Alias for database session that works with Flask app context.
Args:
app: Flask application fixture
Returns:
SQLAlchemy session
"""
with app.app_context():
yield app.db_session
@pytest.fixture
def sample_scan(db):
"""
Create a sample scan in the database for testing.
Args:
db: Database session fixture
Returns:
Scan model instance
"""
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file='/app/configs/test.yaml',
title='Test Scan',
duration=125.5,
triggered_by='test',
json_path='/app/output/scan_report_20251114_103000.json',
html_path='/app/output/scan_report_20251114_103000.html',
zip_path='/app/output/scan_report_20251114_103000.zip',
screenshot_dir='/app/output/scan_report_20251114_103000_screenshots'
)
db.add(scan)
db.commit()
db.refresh(scan)
return scan
# Authentication Fixtures
@pytest.fixture
def app_password():
"""
Test password for authentication tests.
Returns:
Test password string
"""
return 'testpassword123'
@pytest.fixture
def db_with_password(db, app_password):
"""
Database session with application password set.
Args:
db: Database session fixture
app_password: Test password fixture
Returns:
Database session with password configured
"""
settings_manager = SettingsManager(db)
PasswordManager.set_app_password(settings_manager, app_password)
return db
@pytest.fixture
def db_no_password(app):
"""
Database session without application password set.
Args:
app: Flask application fixture
Returns:
Database session without password
"""
with app.app_context():
# Clear any password that might be set
settings_manager = SettingsManager(app.db_session)
settings_manager.delete('app_password')
yield app.db_session
@pytest.fixture
def authenticated_client(client, db_with_password, app_password):
"""
Flask test client with authenticated session.
Args:
client: Flask test client fixture
db_with_password: Database with password set
app_password: Test password fixture
Returns:
Test client with active session
"""
# Log in
client.post('/auth/login', data={
'password': app_password
})
return client
@pytest.fixture
def client_no_password(app):
"""
Flask test client with no password set (for setup testing).
Args:
app: Flask application fixture
Returns:
Test client for testing setup flow
"""
# Create temporary database without password
db_fd, db_path = tempfile.mkstemp(suffix='.db')
test_config = {
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
'SECRET_KEY': 'test-secret-key'
}
test_app = create_app(test_config)
test_client = test_app.test_client()
yield test_client
# Cleanup
os.close(db_fd)
os.unlink(db_path)

View File

@@ -0,0 +1,279 @@
"""
Tests for authentication system.
Tests login, logout, session management, and API authentication.
"""
import pytest
from flask import url_for
from web.auth.models import User
from web.utils.settings import PasswordManager, SettingsManager
class TestUserModel:
"""Tests for User model."""
def test_user_get_valid_id(self, db):
"""Test getting user with valid ID."""
user = User.get('1', db)
assert user is not None
assert user.id == '1'
def test_user_get_invalid_id(self, db):
"""Test getting user with invalid ID."""
user = User.get('invalid', db)
assert user is None
def test_user_properties(self):
"""Test user properties."""
user = User('1')
assert user.is_authenticated is True
assert user.is_active is True
assert user.is_anonymous is False
assert user.get_id() == '1'
def test_user_authenticate_success(self, db, app_password):
"""Test successful authentication."""
user = User.authenticate(app_password, db)
assert user is not None
assert user.id == '1'
def test_user_authenticate_failure(self, db):
"""Test failed authentication with wrong password."""
user = User.authenticate('wrongpassword', db)
assert user is None
def test_user_has_password_set(self, db, app_password):
"""Test checking if password is set."""
# Password is set in fixture
assert User.has_password_set(db) is True
def test_user_has_password_not_set(self, db_no_password):
"""Test checking if password is not set."""
assert User.has_password_set(db_no_password) is False
class TestAuthRoutes:
"""Tests for authentication routes."""
def test_login_page_renders(self, client):
"""Test that login page renders correctly."""
response = client.get('/auth/login')
assert response.status_code == 200
# Note: This will fail until templates are created
# assert b'login' in response.data.lower()
def test_login_success(self, client, app_password):
"""Test successful login."""
response = client.post('/auth/login', data={
'password': app_password
}, follow_redirects=False)
# Should redirect to dashboard (or main.dashboard)
assert response.status_code == 302
def test_login_failure(self, client):
"""Test failed login with wrong password."""
response = client.post('/auth/login', data={
'password': 'wrongpassword'
}, follow_redirects=True)
# Should stay on login page
assert response.status_code == 200
def test_login_redirect_when_authenticated(self, authenticated_client):
"""Test that login page redirects when already logged in."""
response = authenticated_client.get('/auth/login', follow_redirects=False)
# Should redirect to dashboard
assert response.status_code == 302
def test_logout(self, authenticated_client):
"""Test logout functionality."""
response = authenticated_client.get('/auth/logout', follow_redirects=False)
# Should redirect to login page
assert response.status_code == 302
assert '/auth/login' in response.location
def test_logout_when_not_authenticated(self, client):
"""Test logout when not authenticated."""
response = client.get('/auth/logout', follow_redirects=False)
# Should redirect to login page anyway
assert response.status_code == 302
def test_setup_page_renders_when_no_password(self, client_no_password):
"""Test that setup page renders when no password is set."""
response = client_no_password.get('/auth/setup')
assert response.status_code == 200
def test_setup_redirects_when_password_set(self, client):
"""Test that setup page redirects when password already set."""
response = client.get('/auth/setup', follow_redirects=False)
assert response.status_code == 302
assert '/auth/login' in response.location
def test_setup_password_success(self, client_no_password):
"""Test setting password via setup page."""
response = client_no_password.post('/auth/setup', data={
'password': 'newpassword123',
'confirm_password': 'newpassword123'
}, follow_redirects=False)
# Should redirect to login
assert response.status_code == 302
assert '/auth/login' in response.location
def test_setup_password_too_short(self, client_no_password):
"""Test that setup rejects password that's too short."""
response = client_no_password.post('/auth/setup', data={
'password': 'short',
'confirm_password': 'short'
}, follow_redirects=True)
# Should stay on setup page
assert response.status_code == 200
def test_setup_passwords_dont_match(self, client_no_password):
"""Test that setup rejects mismatched passwords."""
response = client_no_password.post('/auth/setup', data={
'password': 'password123',
'confirm_password': 'different123'
}, follow_redirects=True)
# Should stay on setup page
assert response.status_code == 200
class TestAPIAuthentication:
"""Tests for API endpoint authentication."""
def test_scans_list_requires_auth(self, client):
"""Test that listing scans requires authentication."""
response = client.get('/api/scans')
assert response.status_code == 401
data = response.get_json()
assert 'error' in data
assert data['error'] == 'Authentication required'
def test_scans_list_with_auth(self, authenticated_client):
"""Test that listing scans works when authenticated."""
response = authenticated_client.get('/api/scans')
# Should succeed (200) even if empty
assert response.status_code == 200
data = response.get_json()
assert 'scans' in data
def test_scan_trigger_requires_auth(self, client):
"""Test that triggering scan requires authentication."""
response = client.post('/api/scans', json={
'config_file': '/app/configs/test.yaml'
})
assert response.status_code == 401
def test_scan_get_requires_auth(self, client):
"""Test that getting scan details requires authentication."""
response = client.get('/api/scans/1')
assert response.status_code == 401
def test_scan_delete_requires_auth(self, client):
"""Test that deleting scan requires authentication."""
response = client.delete('/api/scans/1')
assert response.status_code == 401
def test_scan_status_requires_auth(self, client):
"""Test that getting scan status requires authentication."""
response = client.get('/api/scans/1/status')
assert response.status_code == 401
def test_settings_get_requires_auth(self, client):
"""Test that getting settings requires authentication."""
response = client.get('/api/settings')
assert response.status_code == 401
def test_settings_update_requires_auth(self, client):
"""Test that updating settings requires authentication."""
response = client.put('/api/settings', json={
'settings': {'test_key': 'test_value'}
})
assert response.status_code == 401
def test_settings_get_with_auth(self, authenticated_client):
"""Test that getting settings works when authenticated."""
response = authenticated_client.get('/api/settings')
assert response.status_code == 200
data = response.get_json()
assert 'settings' in data
def test_schedules_list_requires_auth(self, client):
"""Test that listing schedules requires authentication."""
response = client.get('/api/schedules')
assert response.status_code == 401
def test_alerts_list_requires_auth(self, client):
"""Test that listing alerts requires authentication."""
response = client.get('/api/alerts')
assert response.status_code == 401
def test_health_check_no_auth_required(self, client):
"""Test that health check endpoints don't require authentication."""
# Health checks should be accessible without authentication
response = client.get('/api/scans/health')
assert response.status_code == 200
response = client.get('/api/settings/health')
assert response.status_code == 200
response = client.get('/api/schedules/health')
assert response.status_code == 200
response = client.get('/api/alerts/health')
assert response.status_code == 200
class TestSessionManagement:
"""Tests for session management."""
def test_session_persists_across_requests(self, authenticated_client):
"""Test that session persists across multiple requests."""
# First request - should succeed
response1 = authenticated_client.get('/api/scans')
assert response1.status_code == 200
# Second request - should also succeed (session persists)
response2 = authenticated_client.get('/api/settings')
assert response2.status_code == 200
def test_remember_me_cookie(self, client, app_password):
"""Test remember me functionality."""
response = client.post('/auth/login', data={
'password': app_password,
'remember': 'on'
}, follow_redirects=False)
# Should set remember_me cookie
assert response.status_code == 302
# Note: Actual cookie checking would require inspecting response.headers
class TestNextRedirect:
"""Tests for 'next' parameter redirect."""
def test_login_redirects_to_next(self, client, app_password):
"""Test that login redirects to 'next' parameter."""
response = client.post('/auth/login?next=/api/scans', data={
'password': app_password
}, follow_redirects=False)
assert response.status_code == 302
assert '/api/scans' in response.location
def test_login_without_next_redirects_to_dashboard(self, client, app_password):
"""Test that login without 'next' redirects to dashboard."""
response = client.post('/auth/login', data={
'password': app_password
}, follow_redirects=False)
assert response.status_code == 302
# Should redirect to dashboard
assert 'dashboard' in response.location or response.location == '/'

View File

@@ -0,0 +1,225 @@
"""
Tests for background job execution and scheduler integration.
Tests the APScheduler integration, job queuing, and background scan execution.
"""
import pytest
import time
from datetime import datetime
from web.models import Scan
from web.services.scan_service import ScanService
from web.services.scheduler_service import SchedulerService
class TestBackgroundJobs:
"""Test suite for background job execution."""
def test_scheduler_initialization(self, app):
"""Test that scheduler is initialized with Flask app."""
assert hasattr(app, 'scheduler')
assert app.scheduler is not None
assert app.scheduler.scheduler is not None
assert app.scheduler.scheduler.running
def test_queue_scan_job(self, app, db, sample_config_file):
"""Test queuing a scan for background execution."""
# Create a scan via service
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_file=sample_config_file,
triggered_by='test',
scheduler=app.scheduler
)
# Verify scan was created
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan is not None
assert scan.status == 'running'
# Verify job was queued (check scheduler has the job)
job = app.scheduler.scheduler.get_job(f'scan_{scan_id}')
assert job is not None
assert job.id == f'scan_{scan_id}'
def test_trigger_scan_without_scheduler(self, db, sample_config_file):
"""Test triggering scan without scheduler logs warning."""
# Create scan without scheduler
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_file=sample_config_file,
triggered_by='test',
scheduler=None # No scheduler
)
# Verify scan was created but not queued
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan is not None
assert scan.status == 'running'
def test_scheduler_service_queue_scan(self, app, db, sample_config_file):
"""Test SchedulerService.queue_scan directly."""
# Create scan record first
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
title='Test Scan',
triggered_by='test'
)
db.add(scan)
db.commit()
# Queue the scan
job_id = app.scheduler.queue_scan(scan.id, sample_config_file)
# Verify job was queued
assert job_id == f'scan_{scan.id}'
job = app.scheduler.scheduler.get_job(job_id)
assert job is not None
def test_scheduler_list_jobs(self, app, db, sample_config_file):
"""Test listing scheduled jobs."""
# Queue a few scans
for i in range(3):
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
title=f'Test Scan {i}',
triggered_by='test'
)
db.add(scan)
db.commit()
app.scheduler.queue_scan(scan.id, sample_config_file)
# List jobs
jobs = app.scheduler.list_jobs()
# Should have at least 3 jobs (might have more from other tests)
assert len(jobs) >= 3
# Each job should have required fields
for job in jobs:
assert 'id' in job
assert 'name' in job
assert 'trigger' in job
def test_scheduler_get_job_status(self, app, db, sample_config_file):
"""Test getting status of a specific job."""
# Create and queue a scan
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
title='Test Scan',
triggered_by='test'
)
db.add(scan)
db.commit()
job_id = app.scheduler.queue_scan(scan.id, sample_config_file)
# Get job status
status = app.scheduler.get_job_status(job_id)
assert status is not None
assert status['id'] == job_id
assert status['name'] == f'Scan {scan.id}'
def test_scheduler_get_nonexistent_job(self, app):
"""Test getting status of non-existent job."""
status = app.scheduler.get_job_status('nonexistent_job_id')
assert status is None
def test_scan_timing_fields(self, db, sample_config_file):
"""Test that scan timing fields are properly set."""
# Create scan with started_at
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
title='Test Scan',
triggered_by='test',
started_at=datetime.utcnow()
)
db.add(scan)
db.commit()
# Verify fields exist
assert scan.started_at is not None
assert scan.completed_at is None
assert scan.error_message is None
# Update to completed
scan.status = 'completed'
scan.completed_at = datetime.utcnow()
db.commit()
# Verify fields updated
assert scan.completed_at is not None
assert (scan.completed_at - scan.started_at).total_seconds() >= 0
def test_scan_error_handling(self, db, sample_config_file):
"""Test that error messages are stored correctly."""
# Create failed scan
scan = Scan(
timestamp=datetime.utcnow(),
status='failed',
config_file=sample_config_file,
title='Failed Scan',
triggered_by='test',
started_at=datetime.utcnow(),
completed_at=datetime.utcnow(),
error_message='Test error message'
)
db.add(scan)
db.commit()
# Verify error message stored
assert scan.error_message == 'Test error message'
# Verify status query works
scan_service = ScanService(db)
status = scan_service.get_scan_status(scan.id)
assert status['status'] == 'failed'
assert status['error_message'] == 'Test error message'
@pytest.mark.skip(reason="Requires actual scanner execution - slow test")
def test_background_scan_execution(self, app, db, sample_config_file):
"""
Integration test for actual background scan execution.
This test is skipped by default because it actually runs the scanner,
which requires privileged operations and takes time.
To run: pytest -v -k test_background_scan_execution --run-slow
"""
# Trigger scan
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_file=sample_config_file,
triggered_by='test',
scheduler=app.scheduler
)
# Wait for scan to complete (with timeout)
max_wait = 300 # 5 minutes
start_time = time.time()
while time.time() - start_time < max_wait:
scan = db.query(Scan).filter_by(id=scan_id).first()
if scan.status in ['completed', 'failed']:
break
time.sleep(5)
# Verify scan completed
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan.status in ['completed', 'failed']
if scan.status == 'completed':
assert scan.duration is not None
assert scan.json_path is not None
else:
assert scan.error_message is not None

View File

@@ -0,0 +1,483 @@
"""
Integration tests for Config API endpoints.
Tests all config API endpoints including CSV/YAML upload, listing, downloading,
and deletion with schedule protection.
"""
import pytest
import os
import tempfile
import shutil
from web.app import create_app
from web.models import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
@pytest.fixture
def app():
"""Create test application"""
# Create temporary database
test_db = tempfile.mktemp(suffix='.db')
# Create temporary configs directory
temp_configs_dir = tempfile.mkdtemp()
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{test_db}',
'SECRET_KEY': 'test-secret-key',
'WTF_CSRF_ENABLED': False,
})
# Override configs directory in ConfigService
os.environ['CONFIGS_DIR'] = temp_configs_dir
# Create tables
with app.app_context():
Base.metadata.create_all(bind=app.db_session.get_bind())
yield app
# Cleanup
os.unlink(test_db)
shutil.rmtree(temp_configs_dir)
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def auth_headers(client):
"""Get authentication headers"""
# First register and login a user
from web.auth.models import User
with client.application.app_context():
# Create test user
user = User(username='testuser')
user.set_password('testpass')
client.application.db_session.add(user)
client.application.db_session.commit()
# Login
response = client.post('/auth/login', data={
'username': 'testuser',
'password': 'testpass'
}, follow_redirects=True)
assert response.status_code == 200
# Return empty headers (session-based auth)
return {}
@pytest.fixture
def sample_csv():
"""Sample CSV content"""
return """scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services
Test Scan,Web Servers,10.10.20.4,true,"22,80,443",53,"ssh,http,https"
Test Scan,Web Servers,10.10.20.5,true,22,,"ssh"
"""
@pytest.fixture
def sample_yaml():
"""Sample YAML content"""
return """title: Test Scan
sites:
- name: Web Servers
ips:
- address: 10.10.20.4
expected:
ping: true
tcp_ports: [22, 80, 443]
udp_ports: [53]
services: [ssh, http, https]
"""
class TestListConfigs:
"""Tests for GET /api/configs"""
def test_list_configs_empty(self, client, auth_headers):
"""Test listing configs when none exist"""
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'configs' in data
assert data['configs'] == []
def test_list_configs_with_files(self, client, auth_headers, app, sample_yaml):
"""Test listing configs with existing files"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data['configs']) == 1
assert data['configs'][0]['filename'] == 'test-scan.yaml'
assert data['configs'][0]['title'] == 'Test Scan'
assert 'created_at' in data['configs'][0]
assert 'size_bytes' in data['configs'][0]
assert 'used_by_schedules' in data['configs'][0]
def test_list_configs_requires_auth(self, client):
"""Test that listing configs requires authentication"""
response = client.get('/api/configs')
assert response.status_code in [401, 302] # Unauthorized or redirect
class TestGetConfig:
"""Tests for GET /api/configs/<filename>"""
def test_get_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test getting a valid config file"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs/test-scan.yaml', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['filename'] == 'test-scan.yaml'
assert 'content' in data
assert 'parsed' in data
assert data['parsed']['title'] == 'Test Scan'
def test_get_config_not_found(self, client, auth_headers):
"""Test getting non-existent config"""
response = client.get('/api/configs/nonexistent.yaml', headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert 'error' in data
def test_get_config_requires_auth(self, client):
"""Test that getting config requires authentication"""
response = client.get('/api/configs/test.yaml')
assert response.status_code in [401, 302]
class TestUploadCSV:
"""Tests for POST /api/configs/upload-csv"""
def test_upload_csv_valid(self, client, auth_headers, sample_csv):
"""Test uploading valid CSV"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert 'filename' in result
assert result['filename'].endswith('.yaml')
assert 'preview' in result
def test_upload_csv_no_file(self, client, auth_headers):
"""Test uploading without file"""
response = client.post('/api/configs/upload-csv', data={},
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
def test_upload_csv_invalid_format(self, client, auth_headers):
"""Test uploading invalid CSV"""
from io import BytesIO
invalid_csv = "not,a,valid,csv\nmissing,columns"
data = {
'file': (BytesIO(invalid_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
result = response.get_json()
assert 'error' in result
def test_upload_csv_wrong_extension(self, client, auth_headers):
"""Test uploading file with wrong extension"""
from io import BytesIO
data = {
'file': (BytesIO(b'test'), 'test.txt')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_csv_duplicate_filename(self, client, auth_headers, sample_csv):
"""Test uploading CSV that generates duplicate filename"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
# Upload first time
response1 = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response1.status_code == 200
# Upload second time (should fail)
response2 = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response2.status_code == 400
def test_upload_csv_requires_auth(self, client, sample_csv):
"""Test that uploading CSV requires authentication"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
content_type='multipart/form-data')
assert response.status_code in [401, 302]
class TestUploadYAML:
"""Tests for POST /api/configs/upload-yaml"""
def test_upload_yaml_valid(self, client, auth_headers, sample_yaml):
"""Test uploading valid YAML"""
from io import BytesIO
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert 'filename' in result
def test_upload_yaml_no_file(self, client, auth_headers):
"""Test uploading without file"""
response = client.post('/api/configs/upload-yaml', data={},
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_invalid_syntax(self, client, auth_headers):
"""Test uploading YAML with invalid syntax"""
from io import BytesIO
invalid_yaml = "invalid: yaml: syntax: ["
data = {
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_missing_required_fields(self, client, auth_headers):
"""Test uploading YAML missing required fields"""
from io import BytesIO
invalid_yaml = """sites:
- name: Test
ips:
- address: 10.0.0.1
"""
data = {
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_wrong_extension(self, client, auth_headers):
"""Test uploading file with wrong extension"""
from io import BytesIO
data = {
'file': (BytesIO(b'test'), 'test.txt')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_requires_auth(self, client, sample_yaml):
"""Test that uploading YAML requires authentication"""
from io import BytesIO
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
content_type='multipart/form-data')
assert response.status_code in [401, 302]
class TestDownloadTemplate:
"""Tests for GET /api/configs/template"""
def test_download_template(self, client, auth_headers):
"""Test downloading CSV template"""
response = client.get('/api/configs/template', headers=auth_headers)
assert response.status_code == 200
assert response.content_type == 'text/csv; charset=utf-8'
assert b'scan_title,site_name,ip_address' in response.data
def test_download_template_requires_auth(self, client):
"""Test that downloading template requires authentication"""
response = client.get('/api/configs/template')
assert response.status_code in [401, 302]
class TestDownloadConfig:
"""Tests for GET /api/configs/<filename>/download"""
def test_download_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test downloading existing config"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs/test-scan.yaml/download', headers=auth_headers)
assert response.status_code == 200
assert response.content_type == 'application/x-yaml; charset=utf-8'
assert b'title: Test Scan' in response.data
def test_download_config_not_found(self, client, auth_headers):
"""Test downloading non-existent config"""
response = client.get('/api/configs/nonexistent.yaml/download', headers=auth_headers)
assert response.status_code == 404
def test_download_config_requires_auth(self, client):
"""Test that downloading config requires authentication"""
response = client.get('/api/configs/test.yaml/download')
assert response.status_code in [401, 302]
class TestDeleteConfig:
"""Tests for DELETE /api/configs/<filename>"""
def test_delete_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test deleting a config file"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.delete('/api/configs/test-scan.yaml', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify file is deleted
assert not os.path.exists(config_path)
def test_delete_config_not_found(self, client, auth_headers):
"""Test deleting non-existent config"""
response = client.delete('/api/configs/nonexistent.yaml', headers=auth_headers)
assert response.status_code == 404
def test_delete_config_requires_auth(self, client):
"""Test that deleting config requires authentication"""
response = client.delete('/api/configs/test.yaml')
assert response.status_code in [401, 302]
class TestEndToEndWorkflow:
"""End-to-end workflow tests"""
def test_complete_csv_workflow(self, client, auth_headers, sample_csv):
"""Test complete CSV upload workflow"""
from io import BytesIO
# 1. Download template
response = client.get('/api/configs/template', headers=auth_headers)
assert response.status_code == 200
# 2. Upload CSV
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'workflow-test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
filename = result['filename']
# 3. List configs (should include new one)
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
configs = response.get_json()['configs']
assert any(c['filename'] == filename for c in configs)
# 4. Get config details
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# 5. Download config
response = client.get(f'/api/configs/{filename}/download', headers=auth_headers)
assert response.status_code == 200
# 6. Delete config
response = client.delete(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# 7. Verify deletion
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 404
def test_yaml_upload_workflow(self, client, auth_headers, sample_yaml):
"""Test YAML upload workflow"""
from io import BytesIO
# Upload YAML
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'yaml-workflow.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
filename = response.get_json()['filename']
# Verify it exists
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# Clean up
client.delete(f'/api/configs/{filename}', headers=auth_headers)

View File

@@ -0,0 +1,545 @@
"""
Unit tests for Config Service
Tests the ConfigService class which manages scan configuration files.
"""
import pytest
import os
import yaml
import tempfile
import shutil
from web.services.config_service import ConfigService
class TestConfigService:
"""Test suite for ConfigService"""
@pytest.fixture
def temp_configs_dir(self):
"""Create a temporary directory for config files"""
temp_dir = tempfile.mkdtemp()
yield temp_dir
shutil.rmtree(temp_dir)
@pytest.fixture
def service(self, temp_configs_dir):
"""Create a ConfigService instance with temp directory"""
return ConfigService(configs_dir=temp_configs_dir)
@pytest.fixture
def sample_yaml_config(self):
"""Sample YAML config content"""
return """title: Test Scan
sites:
- name: Web Servers
ips:
- address: 10.10.20.4
expected:
ping: true
tcp_ports: [22, 80, 443]
udp_ports: [53]
services: [ssh, http, https]
"""
@pytest.fixture
def sample_csv_content(self):
"""Sample CSV content"""
return """scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services
Test Scan,Web Servers,10.10.20.4,true,"22,80,443",53,"ssh,http,https"
Test Scan,Web Servers,10.10.20.5,true,22,,"ssh"
"""
def test_list_configs_empty_directory(self, service):
"""Test listing configs when directory is empty"""
configs = service.list_configs()
assert configs == []
def test_list_configs_with_files(self, service, temp_configs_dir, sample_yaml_config):
"""Test listing configs with existing files"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
configs = service.list_configs()
assert len(configs) == 1
assert configs[0]['filename'] == 'test-scan.yaml'
assert configs[0]['title'] == 'Test Scan'
assert 'created_at' in configs[0]
assert 'size_bytes' in configs[0]
assert 'used_by_schedules' in configs[0]
def test_list_configs_ignores_non_yaml_files(self, service, temp_configs_dir):
"""Test that non-YAML files are ignored"""
# Create non-YAML files
with open(os.path.join(temp_configs_dir, 'test.txt'), 'w') as f:
f.write('not a yaml file')
with open(os.path.join(temp_configs_dir, 'readme.md'), 'w') as f:
f.write('# README')
configs = service.list_configs()
assert len(configs) == 0
def test_get_config_valid(self, service, temp_configs_dir, sample_yaml_config):
"""Test getting a valid config file"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
result = service.get_config('test-scan.yaml')
assert result['filename'] == 'test-scan.yaml'
assert 'content' in result
assert 'parsed' in result
assert result['parsed']['title'] == 'Test Scan'
assert len(result['parsed']['sites']) == 1
def test_get_config_not_found(self, service):
"""Test getting a non-existent config"""
with pytest.raises(FileNotFoundError, match="not found"):
service.get_config('nonexistent.yaml')
def test_get_config_invalid_yaml(self, service, temp_configs_dir):
"""Test getting a config with invalid YAML syntax"""
# Create invalid YAML file
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
with open(config_path, 'w') as f:
f.write("invalid: yaml: syntax: [")
with pytest.raises(ValueError, match="Invalid YAML syntax"):
service.get_config('invalid.yaml')
def test_create_from_yaml_valid(self, service, sample_yaml_config):
"""Test creating config from valid YAML"""
filename = service.create_from_yaml('test-scan.yaml', sample_yaml_config)
assert filename == 'test-scan.yaml'
assert service.config_exists('test-scan.yaml')
# Verify content
result = service.get_config('test-scan.yaml')
assert result['parsed']['title'] == 'Test Scan'
def test_create_from_yaml_adds_extension(self, service, sample_yaml_config):
"""Test that .yaml extension is added if missing"""
filename = service.create_from_yaml('test-scan', sample_yaml_config)
assert filename == 'test-scan.yaml'
assert service.config_exists('test-scan.yaml')
def test_create_from_yaml_sanitizes_filename(self, service, sample_yaml_config):
"""Test that filename is sanitized"""
filename = service.create_from_yaml('../../../etc/test.yaml', sample_yaml_config)
# secure_filename should remove path traversal
assert '..' not in filename
assert '/' not in filename
def test_create_from_yaml_duplicate_filename(self, service, temp_configs_dir, sample_yaml_config):
"""Test creating config with duplicate filename"""
# Create first config
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
# Try to create duplicate
with pytest.raises(ValueError, match="already exists"):
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
def test_create_from_yaml_invalid_syntax(self, service):
"""Test creating config with invalid YAML syntax"""
invalid_yaml = "invalid: yaml: syntax: ["
with pytest.raises(ValueError, match="Invalid YAML syntax"):
service.create_from_yaml('test.yaml', invalid_yaml)
def test_create_from_yaml_invalid_structure(self, service):
"""Test creating config with invalid structure (missing title)"""
invalid_config = """sites:
- name: Test
ips:
- address: 10.0.0.1
expected:
ping: true
"""
with pytest.raises(ValueError, match="Missing required field: 'title'"):
service.create_from_yaml('test.yaml', invalid_config)
def test_create_from_csv_valid(self, service, sample_csv_content):
"""Test creating config from valid CSV"""
filename, yaml_content = service.create_from_csv(sample_csv_content)
assert filename == 'test-scan.yaml'
assert service.config_exists(filename)
# Verify YAML was created correctly
result = service.get_config(filename)
assert result['parsed']['title'] == 'Test Scan'
assert len(result['parsed']['sites']) == 1
assert len(result['parsed']['sites'][0]['ips']) == 2
def test_create_from_csv_with_suggested_filename(self, service, sample_csv_content):
"""Test creating config with suggested filename"""
filename, yaml_content = service.create_from_csv(sample_csv_content, 'custom-name.yaml')
assert filename == 'custom-name.yaml'
assert service.config_exists(filename)
def test_create_from_csv_invalid(self, service):
"""Test creating config from invalid CSV"""
invalid_csv = """scan_title,site_name,ip_address
Missing,Columns,Here
"""
with pytest.raises(ValueError, match="CSV parsing failed"):
service.create_from_csv(invalid_csv)
def test_create_from_csv_duplicate_filename(self, service, sample_csv_content):
"""Test creating CSV config with duplicate filename"""
# Create first config
service.create_from_csv(sample_csv_content)
# Try to create duplicate (same title generates same filename)
with pytest.raises(ValueError, match="already exists"):
service.create_from_csv(sample_csv_content)
def test_delete_config_valid(self, service, temp_configs_dir, sample_yaml_config):
"""Test deleting a config file"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
assert service.config_exists('test-scan.yaml')
service.delete_config('test-scan.yaml')
assert not service.config_exists('test-scan.yaml')
def test_delete_config_not_found(self, service):
"""Test deleting non-existent config"""
with pytest.raises(FileNotFoundError, match="not found"):
service.delete_config('nonexistent.yaml')
def test_delete_config_used_by_schedule(self, service, temp_configs_dir, sample_yaml_config, monkeypatch):
"""Test deleting config that is used by schedules - should cascade delete schedules"""
# Create a config file
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
# Mock schedule service interactions
deleted_schedule_ids = []
class MockScheduleService:
def __init__(self, db):
self.db = db
def list_schedules(self, page=1, per_page=10000):
return {
'schedules': [
{
'id': 1,
'name': 'Daily Scan',
'config_file': 'test-scan.yaml',
'enabled': True
},
{
'id': 2,
'name': 'Weekly Audit',
'config_file': 'test-scan.yaml',
'enabled': False # Disabled schedule should also be deleted
}
]
}
def delete_schedule(self, schedule_id):
deleted_schedule_ids.append(schedule_id)
return True
# Mock the ScheduleService import
import sys
from unittest.mock import MagicMock
mock_module = MagicMock()
mock_module.ScheduleService = MockScheduleService
monkeypatch.setitem(sys.modules, 'web.services.schedule_service', mock_module)
# Mock current_app
mock_app = MagicMock()
mock_app.db_session = MagicMock()
import flask
monkeypatch.setattr(flask, 'current_app', mock_app)
# Delete the config - should cascade delete associated schedules
service.delete_config('test-scan.yaml')
# Config should be deleted
assert not service.config_exists('test-scan.yaml')
# Both schedules (enabled and disabled) should be deleted
assert deleted_schedule_ids == [1, 2]
def test_validate_config_content_valid(self, service):
"""Test validating valid config content"""
valid_config = {
'title': 'Test Scan',
'sites': [
{
'name': 'Web Servers',
'ips': [
{
'address': '10.10.20.4',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53]
}
}
]
}
]
}
is_valid, error = service.validate_config_content(valid_config)
assert is_valid is True
assert error == ""
def test_validate_config_content_not_dict(self, service):
"""Test validating non-dict content"""
is_valid, error = service.validate_config_content(['not', 'a', 'dict'])
assert is_valid is False
assert 'must be a dictionary' in error
def test_validate_config_content_missing_title(self, service):
"""Test validating config without title"""
config = {
'sites': []
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "Missing required field: 'title'" in error
def test_validate_config_content_missing_sites(self, service):
"""Test validating config without sites"""
config = {
'title': 'Test'
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "Missing required field: 'sites'" in error
def test_validate_config_content_empty_title(self, service):
"""Test validating config with empty title"""
config = {
'title': '',
'sites': []
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "non-empty string" in error
def test_validate_config_content_sites_not_list(self, service):
"""Test validating config with sites as non-list"""
config = {
'title': 'Test',
'sites': 'not a list'
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "must be a list" in error
def test_validate_config_content_no_sites(self, service):
"""Test validating config with empty sites list"""
config = {
'title': 'Test',
'sites': []
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "at least one site" in error
def test_validate_config_content_site_missing_name(self, service):
"""Test validating site without name"""
config = {
'title': 'Test',
'sites': [
{
'ips': []
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'name'" in error
def test_validate_config_content_site_missing_ips(self, service):
"""Test validating site without ips"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site'
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'ips'" in error
def test_validate_config_content_site_no_ips(self, service):
"""Test validating site with empty ips list"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site',
'ips': []
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "at least one IP" in error
def test_validate_config_content_ip_missing_address(self, service):
"""Test validating IP without address"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'expected': {}
}
]
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'address'" in error
def test_validate_config_content_ip_missing_expected(self, service):
"""Test validating IP without expected"""
config = {
'title': 'Test',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '10.0.0.1'
}
]
}
]
}
is_valid, error = service.validate_config_content(config)
assert is_valid is False
assert "missing required field: 'expected'" in error
def test_generate_filename_from_title_simple(self, service):
"""Test generating filename from simple title"""
filename = service.generate_filename_from_title('Production Scan')
assert filename == 'production-scan.yaml'
def test_generate_filename_from_title_special_chars(self, service):
"""Test generating filename with special characters"""
filename = service.generate_filename_from_title('Prod Scan (2025)!')
assert filename == 'prod-scan-2025.yaml'
assert '(' not in filename
assert ')' not in filename
assert '!' not in filename
def test_generate_filename_from_title_multiple_spaces(self, service):
"""Test generating filename with multiple spaces"""
filename = service.generate_filename_from_title('Test Multiple Spaces')
assert filename == 'test-multiple-spaces.yaml'
# Should not have consecutive hyphens
assert '--' not in filename
def test_generate_filename_from_title_leading_trailing_spaces(self, service):
"""Test generating filename with leading/trailing spaces"""
filename = service.generate_filename_from_title(' Test Scan ')
assert filename == 'test-scan.yaml'
assert not filename.startswith('-')
assert not filename.endswith('-.yaml')
def test_generate_filename_from_title_long(self, service):
"""Test generating filename from long title"""
long_title = 'A' * 300
filename = service.generate_filename_from_title(long_title)
# Should be limited to 200 chars (195 + .yaml)
assert len(filename) <= 200
def test_generate_filename_from_title_empty(self, service):
"""Test generating filename from empty title"""
filename = service.generate_filename_from_title('')
assert filename == 'config.yaml'
def test_generate_filename_from_title_only_special_chars(self, service):
"""Test generating filename from title with only special characters"""
filename = service.generate_filename_from_title('!@#$%^&*()')
assert filename == 'config.yaml'
def test_get_config_path(self, service, temp_configs_dir):
"""Test getting config path"""
path = service.get_config_path('test.yaml')
assert path == os.path.join(temp_configs_dir, 'test.yaml')
def test_config_exists_true(self, service, temp_configs_dir, sample_yaml_config):
"""Test config_exists returns True for existing file"""
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml_config)
assert service.config_exists('test-scan.yaml') is True
def test_config_exists_false(self, service):
"""Test config_exists returns False for non-existent file"""
assert service.config_exists('nonexistent.yaml') is False
def test_get_schedules_using_config_none(self, service):
"""Test getting schedules when none use the config"""
schedules = service.get_schedules_using_config('test.yaml')
# Should return empty list (ScheduleService might not exist in test env)
assert isinstance(schedules, list)
def test_list_configs_sorted_by_date(self, service, temp_configs_dir, sample_yaml_config):
"""Test that configs are sorted by creation date (most recent first)"""
import time
# Create first config
config1_path = os.path.join(temp_configs_dir, 'config1.yaml')
with open(config1_path, 'w') as f:
f.write(sample_yaml_config)
time.sleep(0.1) # Ensure different timestamps
# Create second config
config2_path = os.path.join(temp_configs_dir, 'config2.yaml')
with open(config2_path, 'w') as f:
f.write(sample_yaml_config)
configs = service.list_configs()
assert len(configs) == 2
# Most recent should be first
assert configs[0]['filename'] == 'config2.yaml'
assert configs[1]['filename'] == 'config1.yaml'
def test_list_configs_handles_parse_errors(self, service, temp_configs_dir):
"""Test that list_configs handles files that can't be parsed"""
# Create invalid YAML file
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
with open(config_path, 'w') as f:
f.write("invalid: yaml: [")
# Should not raise error, just use filename as title
configs = service.list_configs()
assert len(configs) == 1
assert configs[0]['filename'] == 'invalid.yaml'

View File

@@ -0,0 +1,267 @@
"""
Tests for error handling and logging functionality.
Tests error handlers, request/response logging, database rollback on errors,
and proper error responses (JSON vs HTML).
"""
import json
import logging
import pytest
from flask import Flask
from sqlalchemy.exc import SQLAlchemyError
from web.app import create_app
@pytest.fixture
def app():
"""Create test Flask app."""
test_config = {
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'SECRET_KEY': 'test-secret-key',
'WTF_CSRF_ENABLED': False
}
app = create_app(test_config)
return app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
class TestErrorHandlers:
"""Test error handler functionality."""
def test_404_json_response(self, client):
"""Test 404 error returns JSON for API requests."""
response = client.get('/api/nonexistent')
assert response.status_code == 404
assert response.content_type == 'application/json'
data = json.loads(response.data)
assert 'error' in data
assert data['error'] == 'Not Found'
assert 'message' in data
def test_404_html_response(self, client):
"""Test 404 error returns HTML for web requests."""
response = client.get('/nonexistent')
assert response.status_code == 404
assert 'text/html' in response.content_type
assert b'404' in response.data
def test_400_json_response(self, client):
"""Test 400 error returns JSON for API requests."""
# Trigger 400 by sending invalid JSON
response = client.post(
'/api/scans',
data='invalid json',
content_type='application/json'
)
assert response.status_code in [400, 401] # 401 if auth required
def test_405_method_not_allowed(self, client):
"""Test 405 error for method not allowed."""
# Try POST to health check (only GET allowed)
response = client.post('/api/scans/health')
assert response.status_code == 405
data = json.loads(response.data)
assert 'error' in data
assert data['error'] == 'Method Not Allowed'
def test_json_accept_header(self, client):
"""Test JSON response when Accept header specifies JSON."""
response = client.get(
'/nonexistent',
headers={'Accept': 'application/json'}
)
assert response.status_code == 404
assert response.content_type == 'application/json'
class TestLogging:
"""Test logging functionality."""
def test_request_logging(self, client, caplog):
"""Test that requests are logged."""
with caplog.at_level(logging.INFO):
response = client.get('/api/scans/health')
# Check log messages
log_messages = [record.message for record in caplog.records]
# Should log incoming request and response
assert any('GET /api/scans/health' in msg for msg in log_messages)
def test_error_logging(self, client, caplog):
"""Test that errors are logged with full context."""
with caplog.at_level(logging.INFO):
client.get('/api/nonexistent')
# Check that 404 was logged
log_messages = [record.message for record in caplog.records]
assert any('not found' in msg.lower() or '404' in msg for msg in log_messages)
def test_request_id_in_logs(self, client, caplog):
"""Test that request ID is included in log records."""
with caplog.at_level(logging.INFO):
client.get('/api/scans/health')
# Check that log records have request_id attribute
for record in caplog.records:
assert hasattr(record, 'request_id')
assert record.request_id # Should not be empty
class TestRequestResponseHandlers:
"""Test request and response handler middleware."""
def test_request_id_header(self, client):
"""Test that response includes X-Request-ID header for API requests."""
response = client.get('/api/scans/health')
assert 'X-Request-ID' in response.headers
def test_request_duration_header(self, client):
"""Test that response includes X-Request-Duration-Ms header."""
response = client.get('/api/scans/health')
assert 'X-Request-Duration-Ms' in response.headers
duration = float(response.headers['X-Request-Duration-Ms'])
assert duration >= 0 # Should be non-negative
def test_security_headers(self, client):
"""Test that security headers are added to API responses."""
response = client.get('/api/scans/health')
# Check security headers
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
assert response.headers.get('X-Frame-Options') == 'DENY'
assert response.headers.get('X-XSS-Protection') == '1; mode=block'
def test_request_timing(self, client):
"""Test that request timing is calculated correctly."""
response = client.get('/api/scans/health')
duration_header = response.headers.get('X-Request-Duration-Ms')
assert duration_header is not None
duration = float(duration_header)
# Should complete in reasonable time (less than 5 seconds)
assert duration < 5000
class TestDatabaseErrorHandling:
"""Test database error handling and rollback."""
def test_database_rollback_on_error(self, app):
"""Test that database session is rolled back on error."""
# This test would require triggering a database error
# For now, just verify the error handler is registered
from sqlalchemy.exc import SQLAlchemyError
# Check that SQLAlchemyError handler is registered
assert SQLAlchemyError in app.error_handler_spec[None]
class TestLogRotation:
"""Test log rotation configuration."""
def test_log_files_created(self, app, tmp_path):
"""Test that log files are created in logs directory."""
import os
from pathlib import Path
# Check that logs directory exists
log_dir = Path('logs')
# Note: In test environment, logs may not be created immediately
# Just verify the configuration is set up
# Verify app logger has handlers
assert len(app.logger.handlers) > 0
# Verify at least one handler is a RotatingFileHandler
from logging.handlers import RotatingFileHandler
has_rotating_handler = any(
isinstance(h, RotatingFileHandler)
for h in app.logger.handlers
)
assert has_rotating_handler, "Should have RotatingFileHandler configured"
def test_log_handler_configuration(self, app):
"""Test that log handlers are configured correctly."""
from logging.handlers import RotatingFileHandler
# Find RotatingFileHandler
rotating_handlers = [
h for h in app.logger.handlers
if isinstance(h, RotatingFileHandler)
]
assert len(rotating_handlers) > 0, "Should have rotating file handlers"
# Check handler configuration
for handler in rotating_handlers:
# Should have max size configured
assert handler.maxBytes > 0
# Should have backup count configured
assert handler.backupCount > 0
class TestStructuredLogging:
"""Test structured logging features."""
def test_log_format_includes_request_id(self, client, caplog):
"""Test that log format includes request ID."""
with caplog.at_level(logging.INFO):
client.get('/api/scans/health')
# Verify log records have request_id
for record in caplog.records:
assert hasattr(record, 'request_id')
def test_error_log_includes_traceback(self, app, caplog):
"""Test that errors are logged with traceback."""
with app.test_request_context('/api/test'):
with caplog.at_level(logging.ERROR):
try:
raise ValueError("Test error")
except ValueError as e:
app.logger.error("Test error occurred", exc_info=True)
# Check that traceback is in logs
log_output = caplog.text
assert 'Test error' in log_output
assert 'Traceback' in log_output or 'ValueError' in log_output
class TestErrorTemplates:
"""Test error template rendering."""
def test_404_template_exists(self, client):
"""Test that 404 error template is rendered."""
response = client.get('/nonexistent')
assert response.status_code == 404
assert b'404' in response.data
assert b'Page Not Found' in response.data or b'Not Found' in response.data
def test_500_template_exists(self, app):
"""Test that 500 error template can be rendered."""
# We can't easily trigger a 500 without breaking the app
# Just verify the template file exists
from pathlib import Path
template_path = Path('web/templates/errors/500.html')
assert template_path.exists(), "500 error template should exist"
def test_error_template_styling(self, client):
"""Test that error templates include styling."""
response = client.get('/nonexistent')
# Should include CSS styling
assert b'style' in response.data or b'css' in response.data.lower()
if __name__ == '__main__':
pytest.main([__file__, '-v'])

267
app/tests/test_scan_api.py Normal file
View File

@@ -0,0 +1,267 @@
"""
Integration tests for Scan API endpoints.
Tests all scan management endpoints including triggering scans,
listing, retrieving details, deleting, and status polling.
"""
import json
import pytest
from pathlib import Path
from datetime import datetime
from web.models import Scan
class TestScanAPIEndpoints:
"""Test suite for scan API endpoints."""
def test_list_scans_empty(self, client, db):
"""Test listing scans when database is empty."""
response = client.get('/api/scans')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scans'] == []
assert data['total'] == 0
assert data['page'] == 1
assert data['per_page'] == 20
def test_list_scans_with_data(self, client, db, sample_scan):
"""Test listing scans with existing data."""
response = client.get('/api/scans')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 1
assert len(data['scans']) == 1
assert data['scans'][0]['id'] == sample_scan.id
def test_list_scans_pagination(self, client, db):
"""Test scan list pagination."""
# Create 25 scans
for i in range(25):
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=f'/app/configs/test{i}.yaml',
title=f'Test Scan {i}',
triggered_by='test'
)
db.add(scan)
db.commit()
# Test page 1
response = client.get('/api/scans?page=1&per_page=10')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 25
assert len(data['scans']) == 10
assert data['page'] == 1
assert data['per_page'] == 10
assert data['total_pages'] == 3
assert data['has_next'] is True
assert data['has_prev'] is False
# Test page 2
response = client.get('/api/scans?page=2&per_page=10')
assert response.status_code == 200
data = json.loads(response.data)
assert len(data['scans']) == 10
assert data['page'] == 2
assert data['has_next'] is True
assert data['has_prev'] is True
def test_list_scans_status_filter(self, client, db):
"""Test filtering scans by status."""
# Create scans with different statuses
for status in ['running', 'completed', 'failed']:
scan = Scan(
timestamp=datetime.utcnow(),
status=status,
config_file='/app/configs/test.yaml',
title=f'{status.capitalize()} Scan',
triggered_by='test'
)
db.add(scan)
db.commit()
# Filter by completed
response = client.get('/api/scans?status=completed')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 1
assert data['scans'][0]['status'] == 'completed'
def test_list_scans_invalid_page(self, client, db):
"""Test listing scans with invalid page parameter."""
response = client.get('/api/scans?page=0')
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_get_scan_success(self, client, db, sample_scan):
"""Test retrieving a specific scan."""
response = client.get(f'/api/scans/{sample_scan.id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['id'] == sample_scan.id
assert data['title'] == sample_scan.title
assert data['status'] == sample_scan.status
def test_get_scan_not_found(self, client, db):
"""Test retrieving a non-existent scan."""
response = client.get('/api/scans/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
assert data['error'] == 'Not found'
def test_trigger_scan_success(self, client, db, sample_config_file):
"""Test triggering a new scan."""
response = client.post('/api/scans',
json={'config_file': str(sample_config_file)},
content_type='application/json'
)
assert response.status_code == 201
data = json.loads(response.data)
assert 'scan_id' in data
assert data['status'] == 'running'
assert data['message'] == 'Scan queued successfully'
# Verify scan was created in database
scan = db.query(Scan).filter_by(id=data['scan_id']).first()
assert scan is not None
assert scan.status == 'running'
assert scan.triggered_by == 'api'
def test_trigger_scan_missing_config_file(self, client, db):
"""Test triggering scan without config_file."""
response = client.post('/api/scans',
json={},
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
assert 'config_file is required' in data['message']
def test_trigger_scan_invalid_config_file(self, client, db):
"""Test triggering scan with non-existent config file."""
response = client.post('/api/scans',
json={'config_file': '/nonexistent/config.yaml'},
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_delete_scan_success(self, client, db, sample_scan):
"""Test deleting a scan."""
scan_id = sample_scan.id
response = client.delete(f'/api/scans/{scan_id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scan_id'] == scan_id
assert 'deleted successfully' in data['message']
# Verify scan was deleted from database
scan = db.query(Scan).filter_by(id=scan_id).first()
assert scan is None
def test_delete_scan_not_found(self, client, db):
"""Test deleting a non-existent scan."""
response = client.delete('/api/scans/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
def test_get_scan_status_success(self, client, db, sample_scan):
"""Test getting scan status."""
response = client.get(f'/api/scans/{sample_scan.id}/status')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scan_id'] == sample_scan.id
assert data['status'] == sample_scan.status
assert 'timestamp' in data
def test_get_scan_status_not_found(self, client, db):
"""Test getting status for non-existent scan."""
response = client.get('/api/scans/99999/status')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
def test_api_error_handling(self, client, db):
"""Test API error responses are properly formatted."""
# Test 404
response = client.get('/api/scans/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
assert 'message' in data
# Test 400
response = client.post('/api/scans', json={})
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
assert 'message' in data
def test_scan_workflow_integration(self, client, db, sample_config_file):
"""
Test complete scan workflow: trigger → status → retrieve → delete.
This integration test verifies the entire scan lifecycle through
the API endpoints.
"""
# Step 1: Trigger scan
response = client.post('/api/scans',
json={'config_file': str(sample_config_file)},
content_type='application/json'
)
assert response.status_code == 201
data = json.loads(response.data)
scan_id = data['scan_id']
# Step 2: Check status
response = client.get(f'/api/scans/{scan_id}/status')
assert response.status_code == 200
data = json.loads(response.data)
assert data['scan_id'] == scan_id
assert data['status'] == 'running'
# Step 3: List scans (verify it appears)
response = client.get('/api/scans')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 1
assert data['scans'][0]['id'] == scan_id
# Step 4: Get scan details
response = client.get(f'/api/scans/{scan_id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['id'] == scan_id
# Step 5: Delete scan
response = client.delete(f'/api/scans/{scan_id}')
assert response.status_code == 200
# Step 6: Verify deletion
response = client.get(f'/api/scans/{scan_id}')
assert response.status_code == 404

View File

@@ -0,0 +1,319 @@
"""
Unit tests for scan comparison functionality.
Tests scan comparison logic including port, service, and certificate comparisons,
as well as drift score calculation.
"""
import pytest
from datetime import datetime
from web.models import Scan, ScanSite, ScanIP, ScanPort
from web.models import ScanService as ScanServiceModel, ScanCertificate
from web.services.scan_service import ScanService
class TestScanComparison:
"""Tests for scan comparison methods."""
@pytest.fixture
def scan1_data(self, test_db, sample_config_file):
"""Create first scan with test data."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
# Get scan and add some test data
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
scan.status = 'completed'
# Create site
site = ScanSite(scan_id=scan.id, site_name='Test Site')
test_db.add(site)
test_db.flush()
# Create IP
ip = ScanIP(
scan_id=scan.id,
site_id=site.id,
ip_address='192.168.1.100',
ping_expected=True,
ping_actual=True
)
test_db.add(ip)
test_db.flush()
# Create ports
port1 = ScanPort(
scan_id=scan.id,
ip_id=ip.id,
port=80,
protocol='tcp',
state='open',
expected=True
)
port2 = ScanPort(
scan_id=scan.id,
ip_id=ip.id,
port=443,
protocol='tcp',
state='open',
expected=True
)
test_db.add(port1)
test_db.add(port2)
test_db.flush()
# Create service
svc1 = ScanServiceModel(
scan_id=scan.id,
port_id=port1.id,
service_name='http',
product='nginx',
version='1.18.0'
)
test_db.add(svc1)
test_db.commit()
return scan_id
@pytest.fixture
def scan2_data(self, test_db, sample_config_file):
"""Create second scan with modified test data."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
# Get scan and add some test data
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
scan.status = 'completed'
# Create site
site = ScanSite(scan_id=scan.id, site_name='Test Site')
test_db.add(site)
test_db.flush()
# Create IP
ip = ScanIP(
scan_id=scan.id,
site_id=site.id,
ip_address='192.168.1.100',
ping_expected=True,
ping_actual=True
)
test_db.add(ip)
test_db.flush()
# Create ports (port 80 removed, 443 kept, 8080 added)
port2 = ScanPort(
scan_id=scan.id,
ip_id=ip.id,
port=443,
protocol='tcp',
state='open',
expected=True
)
port3 = ScanPort(
scan_id=scan.id,
ip_id=ip.id,
port=8080,
protocol='tcp',
state='open',
expected=False
)
test_db.add(port2)
test_db.add(port3)
test_db.flush()
# Create service with updated version
svc2 = ScanServiceModel(
scan_id=scan.id,
port_id=port3.id,
service_name='http',
product='nginx',
version='1.20.0' # Version changed
)
test_db.add(svc2)
test_db.commit()
return scan_id
def test_compare_scans_basic(self, test_db, scan1_data, scan2_data):
"""Test basic scan comparison."""
service = ScanService(test_db)
result = service.compare_scans(scan1_data, scan2_data)
assert result is not None
assert 'scan1' in result
assert 'scan2' in result
assert 'ports' in result
assert 'services' in result
assert 'certificates' in result
assert 'drift_score' in result
# Verify scan metadata
assert result['scan1']['id'] == scan1_data
assert result['scan2']['id'] == scan2_data
def test_compare_scans_not_found(self, test_db):
"""Test comparison with nonexistent scan."""
service = ScanService(test_db)
result = service.compare_scans(999, 998)
assert result is None
def test_compare_ports(self, test_db, scan1_data, scan2_data):
"""Test port comparison logic."""
service = ScanService(test_db)
result = service.compare_scans(scan1_data, scan2_data)
# Scan1 has ports 80, 443
# Scan2 has ports 443, 8080
# Expected: added=[8080], removed=[80], unchanged=[443]
ports = result['ports']
assert len(ports['added']) == 1
assert len(ports['removed']) == 1
assert len(ports['unchanged']) == 1
# Check added port
added_port = ports['added'][0]
assert added_port['port'] == 8080
# Check removed port
removed_port = ports['removed'][0]
assert removed_port['port'] == 80
# Check unchanged port
unchanged_port = ports['unchanged'][0]
assert unchanged_port['port'] == 443
def test_compare_services(self, test_db, scan1_data, scan2_data):
"""Test service comparison logic."""
service = ScanService(test_db)
result = service.compare_scans(scan1_data, scan2_data)
services = result['services']
# Scan1 has nginx 1.18.0 on port 80
# Scan2 has nginx 1.20.0 on port 8080
# These are on different ports, so they should be added/removed, not changed
assert len(services['added']) >= 0
assert len(services['removed']) >= 0
def test_drift_score_calculation(self, test_db, scan1_data, scan2_data):
"""Test drift score calculation."""
service = ScanService(test_db)
result = service.compare_scans(scan1_data, scan2_data)
drift_score = result['drift_score']
# Drift score should be between 0.0 and 1.0
assert 0.0 <= drift_score <= 1.0
# Since we have changes (1 port added, 1 removed), drift should be > 0
assert drift_score > 0.0
def test_compare_identical_scans(self, test_db, scan1_data):
"""Test comparing a scan with itself (should have zero drift)."""
service = ScanService(test_db)
result = service.compare_scans(scan1_data, scan1_data)
# Comparing scan with itself should have zero drift
assert result['drift_score'] == 0.0
assert len(result['ports']['added']) == 0
assert len(result['ports']['removed']) == 0
class TestScanComparisonAPI:
"""Tests for scan comparison API endpoint."""
def test_compare_scans_api(self, client, auth_headers, scan1_data, scan2_data):
"""Test scan comparison API endpoint."""
response = client.get(
f'/api/scans/{scan1_data}/compare/{scan2_data}',
headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert 'scan1' in data
assert 'scan2' in data
assert 'ports' in data
assert 'services' in data
assert 'drift_score' in data
def test_compare_scans_api_not_found(self, client, auth_headers):
"""Test comparison API with nonexistent scans."""
response = client.get(
'/api/scans/999/compare/998',
headers=auth_headers
)
assert response.status_code == 404
data = response.get_json()
assert 'error' in data
def test_compare_scans_api_requires_auth(self, client, scan1_data, scan2_data):
"""Test that comparison API requires authentication."""
response = client.get(f'/api/scans/{scan1_data}/compare/{scan2_data}')
assert response.status_code == 401
class TestHistoricalChartAPI:
"""Tests for historical scan chart API endpoint."""
def test_scan_history_api(self, client, auth_headers, scan1_data):
"""Test scan history API endpoint."""
response = client.get(
f'/api/stats/scan-history/{scan1_data}',
headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
assert 'scans' in data
assert 'labels' in data
assert 'port_counts' in data
assert 'config_file' in data
# Should include at least the scan we created
assert len(data['scans']) >= 1
def test_scan_history_api_not_found(self, client, auth_headers):
"""Test history API with nonexistent scan."""
response = client.get(
'/api/stats/scan-history/999',
headers=auth_headers
)
assert response.status_code == 404
data = response.get_json()
assert 'error' in data
def test_scan_history_api_limit(self, client, auth_headers, scan1_data):
"""Test scan history API with limit parameter."""
response = client.get(
f'/api/stats/scan-history/{scan1_data}?limit=5',
headers=auth_headers
)
assert response.status_code == 200
data = response.get_json()
# Should respect limit
assert len(data['scans']) <= 5
def test_scan_history_api_requires_auth(self, client, scan1_data):
"""Test that history API requires authentication."""
response = client.get(f'/api/stats/scan-history/{scan1_data}')
assert response.status_code == 401

View File

@@ -0,0 +1,402 @@
"""
Unit tests for ScanService class.
Tests scan lifecycle operations: trigger, get, list, delete, and database mapping.
"""
import pytest
from web.models import Scan, ScanSite, ScanIP, ScanPort, ScanService as ScanServiceModel
from web.services.scan_service import ScanService
class TestScanServiceTrigger:
"""Tests for triggering scans."""
def test_trigger_scan_valid_config(self, test_db, sample_config_file):
"""Test triggering a scan with valid config file."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual')
# Verify scan created
assert scan_id is not None
assert isinstance(scan_id, int)
# Verify scan in database
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
assert scan is not None
assert scan.status == 'running'
assert scan.title == 'Test Scan'
assert scan.triggered_by == 'manual'
assert scan.config_file == sample_config_file
def test_trigger_scan_invalid_config(self, test_db, sample_invalid_config_file):
"""Test triggering a scan with invalid config file."""
service = ScanService(test_db)
with pytest.raises(ValueError, match="Invalid config file"):
service.trigger_scan(sample_invalid_config_file)
def test_trigger_scan_nonexistent_file(self, test_db):
"""Test triggering a scan with nonexistent config file."""
service = ScanService(test_db)
with pytest.raises(ValueError, match="does not exist"):
service.trigger_scan('/nonexistent/config.yaml')
def test_trigger_scan_with_schedule(self, test_db, sample_config_file):
"""Test triggering a scan via schedule."""
service = ScanService(test_db)
scan_id = service.trigger_scan(
sample_config_file,
triggered_by='scheduled',
schedule_id=42
)
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
assert scan.triggered_by == 'scheduled'
assert scan.schedule_id == 42
class TestScanServiceGet:
"""Tests for retrieving scans."""
def test_get_scan_not_found(self, test_db):
"""Test getting a nonexistent scan."""
service = ScanService(test_db)
result = service.get_scan(999)
assert result is None
def test_get_scan_found(self, test_db, sample_config_file):
"""Test getting an existing scan."""
service = ScanService(test_db)
# Create a scan
scan_id = service.trigger_scan(sample_config_file)
# Retrieve it
result = service.get_scan(scan_id)
assert result is not None
assert result['id'] == scan_id
assert result['title'] == 'Test Scan'
assert result['status'] == 'running'
assert 'sites' in result
class TestScanServiceList:
"""Tests for listing scans."""
def test_list_scans_empty(self, test_db):
"""Test listing scans when database is empty."""
service = ScanService(test_db)
result = service.list_scans(page=1, per_page=20)
assert result.total == 0
assert len(result.items) == 0
assert result.pages == 0
def test_list_scans_with_data(self, test_db, sample_config_file):
"""Test listing scans with multiple scans."""
service = ScanService(test_db)
# Create 3 scans
for i in range(3):
service.trigger_scan(sample_config_file, triggered_by='api')
# List all scans
result = service.list_scans(page=1, per_page=20)
assert result.total == 3
assert len(result.items) == 3
assert result.pages == 1
def test_list_scans_pagination(self, test_db, sample_config_file):
"""Test pagination."""
service = ScanService(test_db)
# Create 5 scans
for i in range(5):
service.trigger_scan(sample_config_file)
# Get page 1 (2 items per page)
result = service.list_scans(page=1, per_page=2)
assert len(result.items) == 2
assert result.total == 5
assert result.pages == 3
assert result.has_next is True
# Get page 2
result = service.list_scans(page=2, per_page=2)
assert len(result.items) == 2
assert result.has_prev is True
assert result.has_next is True
# Get page 3 (last page)
result = service.list_scans(page=3, per_page=2)
assert len(result.items) == 1
assert result.has_next is False
def test_list_scans_filter_by_status(self, test_db, sample_config_file):
"""Test filtering scans by status."""
service = ScanService(test_db)
# Create scans with different statuses
scan_id_1 = service.trigger_scan(sample_config_file)
scan_id_2 = service.trigger_scan(sample_config_file)
# Mark one as completed
scan = test_db.query(Scan).filter(Scan.id == scan_id_1).first()
scan.status = 'completed'
test_db.commit()
# Filter by running
result = service.list_scans(status_filter='running')
assert result.total == 1
# Filter by completed
result = service.list_scans(status_filter='completed')
assert result.total == 1
def test_list_scans_invalid_status_filter(self, test_db):
"""Test filtering with invalid status."""
service = ScanService(test_db)
with pytest.raises(ValueError, match="Invalid status"):
service.list_scans(status_filter='invalid_status')
class TestScanServiceDelete:
"""Tests for deleting scans."""
def test_delete_scan_not_found(self, test_db):
"""Test deleting a nonexistent scan."""
service = ScanService(test_db)
with pytest.raises(ValueError, match="not found"):
service.delete_scan(999)
def test_delete_scan_success(self, test_db, sample_config_file):
"""Test successful scan deletion."""
service = ScanService(test_db)
# Create a scan
scan_id = service.trigger_scan(sample_config_file)
# Verify it exists
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is not None
# Delete it
result = service.delete_scan(scan_id)
assert result is True
# Verify it's gone
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is None
class TestScanServiceStatus:
"""Tests for scan status retrieval."""
def test_get_scan_status_not_found(self, test_db):
"""Test getting status of nonexistent scan."""
service = ScanService(test_db)
result = service.get_scan_status(999)
assert result is None
def test_get_scan_status_running(self, test_db, sample_config_file):
"""Test getting status of running scan."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file)
status = service.get_scan_status(scan_id)
assert status is not None
assert status['scan_id'] == scan_id
assert status['status'] == 'running'
assert status['progress'] == 'In progress'
assert status['title'] == 'Test Scan'
def test_get_scan_status_completed(self, test_db, sample_config_file):
"""Test getting status of completed scan."""
service = ScanService(test_db)
# Create and mark as completed
scan_id = service.trigger_scan(sample_config_file)
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
scan.status = 'completed'
scan.duration = 125.5
test_db.commit()
status = service.get_scan_status(scan_id)
assert status['status'] == 'completed'
assert status['progress'] == 'Complete'
assert status['duration'] == 125.5
class TestScanServiceDatabaseMapping:
"""Tests for mapping scan reports to database models."""
def test_save_scan_to_db(self, test_db, sample_config_file, sample_scan_report):
"""Test saving a complete scan report to database."""
service = ScanService(test_db)
# Create a scan
scan_id = service.trigger_scan(sample_config_file)
# Save report to database
service._save_scan_to_db(sample_scan_report, scan_id, status='completed')
# Verify scan updated
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
assert scan.status == 'completed'
assert scan.duration == 125.5
# Verify sites created
sites = test_db.query(ScanSite).filter(ScanSite.scan_id == scan_id).all()
assert len(sites) == 1
assert sites[0].site_name == 'Test Site'
# Verify IPs created
ips = test_db.query(ScanIP).filter(ScanIP.scan_id == scan_id).all()
assert len(ips) == 1
assert ips[0].ip_address == '192.168.1.10'
assert ips[0].ping_expected is True
assert ips[0].ping_actual is True
# Verify ports created (TCP: 22, 80, 443, 8080 | UDP: 53)
ports = test_db.query(ScanPort).filter(ScanPort.scan_id == scan_id).all()
assert len(ports) == 5 # 4 TCP + 1 UDP
# Verify TCP ports
tcp_ports = [p for p in ports if p.protocol == 'tcp']
assert len(tcp_ports) == 4
tcp_port_numbers = sorted([p.port for p in tcp_ports])
assert tcp_port_numbers == [22, 80, 443, 8080]
# Verify UDP ports
udp_ports = [p for p in ports if p.protocol == 'udp']
assert len(udp_ports) == 1
assert udp_ports[0].port == 53
# Verify services created
services = test_db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id
).all()
assert len(services) == 4 # SSH, HTTP (80), HTTPS, HTTP (8080)
# Find HTTPS service
https_service = next(
(s for s in services if s.service_name == 'https'), None
)
assert https_service is not None
assert https_service.product == 'nginx'
assert https_service.version == '1.24.0'
assert https_service.http_protocol == 'https'
assert https_service.screenshot_path == 'screenshots/192_168_1_10_443.png'
def test_map_port_expected_vs_actual(self, test_db, sample_config_file, sample_scan_report):
"""Test that expected vs actual ports are correctly flagged."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file)
service._save_scan_to_db(sample_scan_report, scan_id)
# Check TCP ports
tcp_ports = test_db.query(ScanPort).filter(
ScanPort.scan_id == scan_id,
ScanPort.protocol == 'tcp'
).all()
# Ports 22, 80, 443 were expected
expected_ports = {22, 80, 443}
for port in tcp_ports:
if port.port in expected_ports:
assert port.expected is True, f"Port {port.port} should be expected"
else:
# Port 8080 was not expected
assert port.expected is False, f"Port {port.port} should not be expected"
def test_map_certificate_and_tls(self, test_db, sample_config_file, sample_scan_report):
"""Test that certificate and TLS data are correctly mapped."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file)
service._save_scan_to_db(sample_scan_report, scan_id)
# Find HTTPS service
https_service = test_db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id,
ScanServiceModel.service_name == 'https'
).first()
assert https_service is not None
# Verify certificate created
assert len(https_service.certificates) == 1
cert = https_service.certificates[0]
assert cert.subject == 'CN=example.com'
assert cert.issuer == "CN=Let's Encrypt Authority"
assert cert.days_until_expiry == 365
assert cert.is_self_signed is False
# Verify SANs
import json
sans = json.loads(cert.sans)
assert 'example.com' in sans
assert 'www.example.com' in sans
# Verify TLS versions
assert len(cert.tls_versions) == 2
tls_12 = next((t for t in cert.tls_versions if t.tls_version == 'TLS 1.2'), None)
assert tls_12 is not None
assert tls_12.supported is True
tls_13 = next((t for t in cert.tls_versions if t.tls_version == 'TLS 1.3'), None)
assert tls_13 is not None
assert tls_13.supported is True
def test_get_scan_with_full_details(self, test_db, sample_config_file, sample_scan_report):
"""Test retrieving scan with all nested relationships."""
service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file)
service._save_scan_to_db(sample_scan_report, scan_id)
# Get full scan details
result = service.get_scan(scan_id)
assert result is not None
assert len(result['sites']) == 1
site = result['sites'][0]
assert site['name'] == 'Test Site'
assert len(site['ips']) == 1
ip = site['ips'][0]
assert ip['address'] == '192.168.1.10'
assert len(ip['ports']) == 5 # 4 TCP + 1 UDP
# Find HTTPS port
https_port = next(
(p for p in ip['ports'] if p['port'] == 443), None
)
assert https_port is not None
assert len(https_port['services']) == 1
service_data = https_port['services'][0]
assert service_data['service_name'] == 'https'
assert 'certificates' in service_data
assert len(service_data['certificates']) == 1
cert = service_data['certificates'][0]
assert cert['subject'] == 'CN=example.com'
assert 'tls_versions' in cert
assert len(cert['tls_versions']) == 2

View File

@@ -0,0 +1,639 @@
"""
Integration tests for Schedule API endpoints.
Tests all schedule management endpoints including creating, listing,
updating, deleting schedules, and manually triggering scheduled scans.
"""
import json
import pytest
from datetime import datetime
from web.models import Schedule, Scan
@pytest.fixture
def sample_schedule(db, sample_config_file):
"""
Create a sample schedule in the database for testing.
Args:
db: Database session fixture
sample_config_file: Path to test config file
Returns:
Schedule model instance
"""
schedule = Schedule(
name='Daily Test Scan',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True,
last_run=None,
next_run=datetime(2025, 11, 15, 2, 0, 0),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(schedule)
db.commit()
db.refresh(schedule)
return schedule
class TestScheduleAPIEndpoints:
"""Test suite for schedule API endpoints."""
def test_list_schedules_empty(self, client, db):
"""Test listing schedules when database is empty."""
response = client.get('/api/schedules')
assert response.status_code == 200
data = json.loads(response.data)
assert data['schedules'] == []
assert data['total'] == 0
assert data['page'] == 1
assert data['per_page'] == 20
def test_list_schedules_populated(self, client, db, sample_schedule):
"""Test listing schedules with existing data."""
response = client.get('/api/schedules')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 1
assert len(data['schedules']) == 1
assert data['schedules'][0]['id'] == sample_schedule.id
assert data['schedules'][0]['name'] == sample_schedule.name
assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression
def test_list_schedules_pagination(self, client, db, sample_config_file):
"""Test schedule list pagination."""
# Create 25 schedules
for i in range(25):
schedule = Schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True,
created_at=datetime.utcnow()
)
db.add(schedule)
db.commit()
# Test page 1
response = client.get('/api/schedules?page=1&per_page=10')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 25
assert len(data['schedules']) == 10
assert data['page'] == 1
assert data['per_page'] == 10
assert data['pages'] == 3
# Test page 2
response = client.get('/api/schedules?page=2&per_page=10')
assert response.status_code == 200
data = json.loads(response.data)
assert len(data['schedules']) == 10
assert data['page'] == 2
def test_list_schedules_filter_enabled(self, client, db, sample_config_file):
"""Test filtering schedules by enabled status."""
# Create enabled and disabled schedules
for i in range(3):
schedule = Schedule(
name=f'Enabled Schedule {i}',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True,
created_at=datetime.utcnow()
)
db.add(schedule)
for i in range(2):
schedule = Schedule(
name=f'Disabled Schedule {i}',
config_file=sample_config_file,
cron_expression='0 3 * * *',
enabled=False,
created_at=datetime.utcnow()
)
db.add(schedule)
db.commit()
# Filter by enabled=true
response = client.get('/api/schedules?enabled=true')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 3
for schedule in data['schedules']:
assert schedule['enabled'] is True
# Filter by enabled=false
response = client.get('/api/schedules?enabled=false')
assert response.status_code == 200
data = json.loads(response.data)
assert data['total'] == 2
for schedule in data['schedules']:
assert schedule['enabled'] is False
def test_get_schedule(self, client, db, sample_schedule):
"""Test getting schedule details."""
response = client.get(f'/api/schedules/{sample_schedule.id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['id'] == sample_schedule.id
assert data['name'] == sample_schedule.name
assert data['config_file'] == sample_schedule.config_file
assert data['cron_expression'] == sample_schedule.cron_expression
assert data['enabled'] == sample_schedule.enabled
assert 'history' in data
def test_get_schedule_not_found(self, client, db):
"""Test getting non-existent schedule."""
response = client.get('/api/schedules/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
assert 'not found' in data['error'].lower()
def test_create_schedule(self, client, db, sample_config_file):
"""Test creating a new schedule."""
schedule_data = {
'name': 'New Test Schedule',
'config_file': sample_config_file,
'cron_expression': '0 3 * * *',
'enabled': True
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 201
data = json.loads(response.data)
assert 'schedule_id' in data
assert data['message'] == 'Schedule created successfully'
assert 'schedule' in data
# Verify schedule in database
schedule = db.query(Schedule).filter(Schedule.id == data['schedule_id']).first()
assert schedule is not None
assert schedule.name == schedule_data['name']
assert schedule.cron_expression == schedule_data['cron_expression']
def test_create_schedule_missing_fields(self, client, db):
"""Test creating schedule with missing required fields."""
# Missing cron_expression
schedule_data = {
'name': 'Incomplete Schedule',
'config_file': '/app/configs/test.yaml'
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
assert 'missing' in data['error'].lower()
def test_create_schedule_invalid_cron(self, client, db, sample_config_file):
"""Test creating schedule with invalid cron expression."""
schedule_data = {
'name': 'Invalid Cron Schedule',
'config_file': sample_config_file,
'cron_expression': 'invalid cron'
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower()
def test_create_schedule_invalid_config(self, client, db):
"""Test creating schedule with non-existent config file."""
schedule_data = {
'name': 'Invalid Config Schedule',
'config_file': '/nonexistent/config.yaml',
'cron_expression': '0 2 * * *'
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
assert 'not found' in data['error'].lower()
def test_update_schedule(self, client, db, sample_schedule):
"""Test updating schedule fields."""
update_data = {
'name': 'Updated Schedule Name',
'cron_expression': '0 4 * * *'
}
response = client.put(
f'/api/schedules/{sample_schedule.id}',
data=json.dumps(update_data),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['message'] == 'Schedule updated successfully'
assert data['schedule']['name'] == update_data['name']
assert data['schedule']['cron_expression'] == update_data['cron_expression']
# Verify in database
db.refresh(sample_schedule)
assert sample_schedule.name == update_data['name']
assert sample_schedule.cron_expression == update_data['cron_expression']
def test_update_schedule_not_found(self, client, db):
"""Test updating non-existent schedule."""
update_data = {'name': 'New Name'}
response = client.put(
'/api/schedules/99999',
data=json.dumps(update_data),
content_type='application/json'
)
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
def test_update_schedule_invalid_cron(self, client, db, sample_schedule):
"""Test updating schedule with invalid cron expression."""
update_data = {'cron_expression': 'invalid'}
response = client.put(
f'/api/schedules/{sample_schedule.id}',
data=json.dumps(update_data),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_update_schedule_toggle_enabled(self, client, db, sample_schedule):
"""Test enabling/disabling schedule."""
# Disable schedule
response = client.put(
f'/api/schedules/{sample_schedule.id}',
data=json.dumps({'enabled': False}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['schedule']['enabled'] is False
# Enable schedule
response = client.put(
f'/api/schedules/{sample_schedule.id}',
data=json.dumps({'enabled': True}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['schedule']['enabled'] is True
def test_update_schedule_no_data(self, client, db, sample_schedule):
"""Test updating schedule with no data."""
response = client.put(
f'/api/schedules/{sample_schedule.id}',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert 'error' in data
def test_delete_schedule(self, client, db, sample_schedule):
"""Test deleting a schedule."""
schedule_id = sample_schedule.id
response = client.delete(f'/api/schedules/{schedule_id}')
assert response.status_code == 200
data = json.loads(response.data)
assert data['message'] == 'Schedule deleted successfully'
assert data['schedule_id'] == schedule_id
# Verify deletion in database
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
assert schedule is None
def test_delete_schedule_not_found(self, client, db):
"""Test deleting non-existent schedule."""
response = client.delete('/api/schedules/99999')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_config_file):
"""Test that deleting schedule preserves associated scans."""
# Create a scan associated with the schedule
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=sample_config_file,
title='Test Scan',
triggered_by='scheduled',
schedule_id=sample_schedule.id
)
db.add(scan)
db.commit()
scan_id = scan.id
# Delete schedule
response = client.delete(f'/api/schedules/{sample_schedule.id}')
assert response.status_code == 200
# Verify scan still exists
scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan is not None
assert scan.schedule_id is None # Schedule ID becomes null
def test_trigger_schedule(self, client, db, sample_schedule):
"""Test manually triggering a scheduled scan."""
response = client.post(f'/api/schedules/{sample_schedule.id}/trigger')
assert response.status_code == 201
data = json.loads(response.data)
assert data['message'] == 'Scan triggered successfully'
assert 'scan_id' in data
assert data['schedule_id'] == sample_schedule.id
# Verify scan was created
scan = db.query(Scan).filter(Scan.id == data['scan_id']).first()
assert scan is not None
assert scan.triggered_by == 'manual'
assert scan.schedule_id == sample_schedule.id
assert scan.config_file == sample_schedule.config_file
def test_trigger_schedule_not_found(self, client, db):
"""Test triggering non-existent schedule."""
response = client.post('/api/schedules/99999/trigger')
assert response.status_code == 404
data = json.loads(response.data)
assert 'error' in data
def test_get_schedule_with_history(self, client, db, sample_schedule, sample_config_file):
"""Test getting schedule includes execution history."""
# Create some scans for this schedule
for i in range(5):
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=sample_config_file,
title=f'Scheduled Scan {i}',
triggered_by='scheduled',
schedule_id=sample_schedule.id
)
db.add(scan)
db.commit()
response = client.get(f'/api/schedules/{sample_schedule.id}')
assert response.status_code == 200
data = json.loads(response.data)
assert 'history' in data
assert len(data['history']) == 5
def test_schedule_workflow_integration(self, client, db, sample_config_file):
"""Test complete schedule workflow: create → update → trigger → delete."""
# 1. Create schedule
schedule_data = {
'name': 'Integration Test Schedule',
'config_file': sample_config_file,
'cron_expression': '0 2 * * *',
'enabled': True
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 201
schedule_id = json.loads(response.data)['schedule_id']
# 2. Get schedule
response = client.get(f'/api/schedules/{schedule_id}')
assert response.status_code == 200
# 3. Update schedule
response = client.put(
f'/api/schedules/{schedule_id}',
data=json.dumps({'name': 'Updated Integration Test'}),
content_type='application/json'
)
assert response.status_code == 200
# 4. Trigger schedule
response = client.post(f'/api/schedules/{schedule_id}/trigger')
assert response.status_code == 201
scan_id = json.loads(response.data)['scan_id']
# 5. Verify scan was created
scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan is not None
# 6. Delete schedule
response = client.delete(f'/api/schedules/{schedule_id}')
assert response.status_code == 200
# 7. Verify schedule deleted
response = client.get(f'/api/schedules/{schedule_id}')
assert response.status_code == 404
# 8. Verify scan still exists
scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan is not None
def test_list_schedules_ordering(self, client, db, sample_config_file):
"""Test that schedules are ordered by next_run time."""
# Create schedules with different next_run times
schedules = []
for i in range(3):
schedule = Schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True,
next_run=datetime(2025, 11, 15 + i, 2, 0, 0),
created_at=datetime.utcnow()
)
db.add(schedule)
schedules.append(schedule)
# Create a disabled schedule (next_run is None)
disabled_schedule = Schedule(
name='Disabled Schedule',
config_file=sample_config_file,
cron_expression='0 3 * * *',
enabled=False,
next_run=None,
created_at=datetime.utcnow()
)
db.add(disabled_schedule)
db.commit()
response = client.get('/api/schedules')
assert response.status_code == 200
data = json.loads(response.data)
returned_schedules = data['schedules']
# Schedules with next_run should come before those without
# Within those with next_run, they should be ordered by time
assert returned_schedules[0]['id'] == schedules[0].id
assert returned_schedules[1]['id'] == schedules[1].id
assert returned_schedules[2]['id'] == schedules[2].id
assert returned_schedules[3]['id'] == disabled_schedule.id
def test_create_schedule_with_disabled(self, client, db, sample_config_file):
"""Test creating a disabled schedule."""
schedule_data = {
'name': 'Disabled Schedule',
'config_file': sample_config_file,
'cron_expression': '0 2 * * *',
'enabled': False
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 201
data = json.loads(response.data)
assert data['schedule']['enabled'] is False
assert data['schedule']['next_run'] is None # Disabled schedules have no next_run
class TestScheduleAPIAuthentication:
"""Test suite for schedule API authentication."""
def test_schedules_require_authentication(self, app):
"""Test that all schedule endpoints require authentication."""
# Create unauthenticated client
client = app.test_client()
endpoints = [
('GET', '/api/schedules'),
('GET', '/api/schedules/1'),
('POST', '/api/schedules'),
('PUT', '/api/schedules/1'),
('DELETE', '/api/schedules/1'),
('POST', '/api/schedules/1/trigger')
]
for method, endpoint in endpoints:
if method == 'GET':
response = client.get(endpoint)
elif method == 'POST':
response = client.post(
endpoint,
data=json.dumps({}),
content_type='application/json'
)
elif method == 'PUT':
response = client.put(
endpoint,
data=json.dumps({}),
content_type='application/json'
)
elif method == 'DELETE':
response = client.delete(endpoint)
# Should redirect to login or return 401
assert response.status_code in [302, 401], \
f"{method} {endpoint} should require authentication"
class TestScheduleAPICronValidation:
"""Test suite for cron expression validation."""
def test_valid_cron_expressions(self, client, db, sample_config_file):
"""Test various valid cron expressions."""
valid_expressions = [
'0 2 * * *', # Daily at 2am
'*/15 * * * *', # Every 15 minutes
'0 0 * * 0', # Weekly on Sunday
'0 0 1 * *', # Monthly on 1st
'0 */4 * * *', # Every 4 hours
]
for cron_expr in valid_expressions:
schedule_data = {
'name': f'Schedule for {cron_expr}',
'config_file': sample_config_file,
'cron_expression': cron_expr
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 201, \
f"Valid cron expression '{cron_expr}' should be accepted"
def test_invalid_cron_expressions(self, client, db, sample_config_file):
"""Test various invalid cron expressions."""
invalid_expressions = [
'invalid',
'60 2 * * *', # Invalid minute
'0 25 * * *', # Invalid hour
'0 0 32 * *', # Invalid day
'0 0 * 13 *', # Invalid month
'0 0 * * 8', # Invalid day of week
]
for cron_expr in invalid_expressions:
schedule_data = {
'name': f'Schedule for {cron_expr}',
'config_file': sample_config_file,
'cron_expression': cron_expr
}
response = client.post(
'/api/schedules',
data=json.dumps(schedule_data),
content_type='application/json'
)
assert response.status_code == 400, \
f"Invalid cron expression '{cron_expr}' should be rejected"

View File

@@ -0,0 +1,671 @@
"""
Unit tests for ScheduleService class.
Tests schedule lifecycle operations: create, get, list, update, delete, and
cron expression validation.
"""
import pytest
from datetime import datetime, timedelta
from web.models import Schedule, Scan
from web.services.schedule_service import ScheduleService
class TestScheduleServiceCreate:
"""Tests for creating schedules."""
def test_create_schedule_valid(self, test_db, sample_config_file):
"""Test creating a schedule with valid parameters."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Daily Scan',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Verify schedule created
assert schedule_id is not None
assert isinstance(schedule_id, int)
# Verify schedule in database
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first()
assert schedule is not None
assert schedule.name == 'Daily Scan'
assert schedule.config_file == sample_config_file
assert schedule.cron_expression == '0 2 * * *'
assert schedule.enabled is True
assert schedule.next_run is not None
assert schedule.last_run is None
def test_create_schedule_disabled(self, test_db, sample_config_file):
"""Test creating a disabled schedule."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Disabled Scan',
config_file=sample_config_file,
cron_expression='0 3 * * *',
enabled=False
)
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first()
assert schedule.enabled is False
assert schedule.next_run is None
def test_create_schedule_invalid_cron(self, test_db, sample_config_file):
"""Test creating a schedule with invalid cron expression."""
service = ScheduleService(test_db)
with pytest.raises(ValueError, match="Invalid cron expression"):
service.create_schedule(
name='Invalid Schedule',
config_file=sample_config_file,
cron_expression='invalid cron',
enabled=True
)
def test_create_schedule_nonexistent_config(self, test_db):
"""Test creating a schedule with nonexistent config file."""
service = ScheduleService(test_db)
with pytest.raises(ValueError, match="Config file not found"):
service.create_schedule(
name='Bad Config',
config_file='/nonexistent/config.yaml',
cron_expression='0 2 * * *',
enabled=True
)
def test_create_schedule_various_cron_expressions(self, test_db, sample_config_file):
"""Test creating schedules with various valid cron expressions."""
service = ScheduleService(test_db)
cron_expressions = [
'0 0 * * *', # Daily at midnight
'*/15 * * * *', # Every 15 minutes
'0 2 * * 0', # Weekly on Sunday at 2 AM
'0 0 1 * *', # Monthly on the 1st at midnight
'30 14 * * 1-5', # Weekdays at 2:30 PM
]
for i, cron in enumerate(cron_expressions):
schedule_id = service.create_schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
cron_expression=cron,
enabled=True
)
assert schedule_id is not None
class TestScheduleServiceGet:
"""Tests for retrieving schedules."""
def test_get_schedule_not_found(self, test_db):
"""Test getting a nonexistent schedule."""
service = ScheduleService(test_db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.get_schedule(999)
def test_get_schedule_found(self, test_db, sample_config_file):
"""Test getting an existing schedule."""
service = ScheduleService(test_db)
# Create a schedule
schedule_id = service.create_schedule(
name='Test Schedule',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Retrieve it
result = service.get_schedule(schedule_id)
assert result is not None
assert result['id'] == schedule_id
assert result['name'] == 'Test Schedule'
assert result['cron_expression'] == '0 2 * * *'
assert result['enabled'] is True
assert 'history' in result
assert isinstance(result['history'], list)
def test_get_schedule_with_history(self, test_db, sample_config_file):
"""Test getting schedule includes execution history."""
service = ScheduleService(test_db)
# Create schedule
schedule_id = service.create_schedule(
name='Test Schedule',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Create associated scans
for i in range(3):
scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i),
status='completed',
config_file=sample_config_file,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
# Get schedule
result = service.get_schedule(schedule_id)
assert len(result['history']) == 3
assert result['history'][0]['title'] == 'Scan 0' # Most recent first
class TestScheduleServiceList:
"""Tests for listing schedules."""
def test_list_schedules_empty(self, test_db):
"""Test listing schedules when database is empty."""
service = ScheduleService(test_db)
result = service.list_schedules(page=1, per_page=20)
assert result['total'] == 0
assert len(result['schedules']) == 0
assert result['page'] == 1
assert result['per_page'] == 20
def test_list_schedules_populated(self, test_db, sample_config_file):
"""Test listing schedules with data."""
service = ScheduleService(test_db)
# Create multiple schedules
for i in range(5):
service.create_schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
result = service.list_schedules(page=1, per_page=20)
assert result['total'] == 5
assert len(result['schedules']) == 5
assert all('name' in s for s in result['schedules'])
def test_list_schedules_pagination(self, test_db, sample_config_file):
"""Test schedule pagination."""
service = ScheduleService(test_db)
# Create 25 schedules
for i in range(25):
service.create_schedule(
name=f'Schedule {i:02d}',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Get first page
result_page1 = service.list_schedules(page=1, per_page=10)
assert len(result_page1['schedules']) == 10
assert result_page1['total'] == 25
assert result_page1['pages'] == 3
# Get second page
result_page2 = service.list_schedules(page=2, per_page=10)
assert len(result_page2['schedules']) == 10
# Get third page
result_page3 = service.list_schedules(page=3, per_page=10)
assert len(result_page3['schedules']) == 5
def test_list_schedules_filter_enabled(self, test_db, sample_config_file):
"""Test filtering schedules by enabled status."""
service = ScheduleService(test_db)
# Create enabled and disabled schedules
for i in range(3):
service.create_schedule(
name=f'Enabled {i}',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
for i in range(2):
service.create_schedule(
name=f'Disabled {i}',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=False
)
# Filter enabled only
result_enabled = service.list_schedules(enabled_filter=True)
assert result_enabled['total'] == 3
# Filter disabled only
result_disabled = service.list_schedules(enabled_filter=False)
assert result_disabled['total'] == 2
# No filter
result_all = service.list_schedules(enabled_filter=None)
assert result_all['total'] == 5
class TestScheduleServiceUpdate:
"""Tests for updating schedules."""
def test_update_schedule_name(self, test_db, sample_config_file):
"""Test updating schedule name."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Old Name',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
result = service.update_schedule(schedule_id, name='New Name')
assert result['name'] == 'New Name'
assert result['cron_expression'] == '0 2 * * *'
def test_update_schedule_cron(self, test_db, sample_config_file):
"""Test updating cron expression recalculates next_run."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
original = service.get_schedule(schedule_id)
original_next_run = original['next_run']
# Update cron expression
result = service.update_schedule(
schedule_id,
cron_expression='0 3 * * *'
)
# Next run should be recalculated
assert result['cron_expression'] == '0 3 * * *'
assert result['next_run'] != original_next_run
def test_update_schedule_invalid_cron(self, test_db, sample_config_file):
"""Test updating with invalid cron expression fails."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
with pytest.raises(ValueError, match="Invalid cron expression"):
service.update_schedule(schedule_id, cron_expression='invalid')
def test_update_schedule_not_found(self, test_db):
"""Test updating nonexistent schedule fails."""
service = ScheduleService(test_db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.update_schedule(999, name='New Name')
def test_update_schedule_invalid_config_file(self, test_db, sample_config_file):
"""Test updating with nonexistent config file fails."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
with pytest.raises(ValueError, match="Config file not found"):
service.update_schedule(schedule_id, config_file='/nonexistent.yaml')
class TestScheduleServiceDelete:
"""Tests for deleting schedules."""
def test_delete_schedule(self, test_db, sample_config_file):
"""Test deleting a schedule."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='To Delete',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Verify exists
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is not None
# Delete
result = service.delete_schedule(schedule_id)
assert result is True
# Verify deleted
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is None
def test_delete_schedule_not_found(self, test_db):
"""Test deleting nonexistent schedule fails."""
service = ScheduleService(test_db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.delete_schedule(999)
def test_delete_schedule_preserves_scans(self, test_db, sample_config_file):
"""Test that deleting schedule preserves associated scans."""
service = ScheduleService(test_db)
# Create schedule
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Create associated scan
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=sample_config_file,
title='Test Scan',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
scan_id = scan.id
# Delete schedule
service.delete_schedule(schedule_id)
# Verify scan still exists (schedule_id becomes null)
remaining_scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
assert remaining_scan is not None
assert remaining_scan.schedule_id is None
class TestScheduleServiceToggle:
"""Tests for toggling schedule enabled status."""
def test_toggle_enabled_to_disabled(self, test_db, sample_config_file):
"""Test disabling an enabled schedule."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
result = service.toggle_enabled(schedule_id, enabled=False)
assert result['enabled'] is False
assert result['next_run'] is None
def test_toggle_disabled_to_enabled(self, test_db, sample_config_file):
"""Test enabling a disabled schedule."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=False
)
result = service.toggle_enabled(schedule_id, enabled=True)
assert result['enabled'] is True
assert result['next_run'] is not None
class TestScheduleServiceRunTimes:
"""Tests for updating run times."""
def test_update_run_times(self, test_db, sample_config_file):
"""Test updating last_run and next_run."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
last_run = datetime.utcnow()
next_run = datetime.utcnow() + timedelta(days=1)
result = service.update_run_times(schedule_id, last_run, next_run)
assert result is True
schedule = service.get_schedule(schedule_id)
assert schedule['last_run'] is not None
assert schedule['next_run'] is not None
def test_update_run_times_not_found(self, test_db):
"""Test updating run times for nonexistent schedule."""
service = ScheduleService(test_db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.update_run_times(
999,
datetime.utcnow(),
datetime.utcnow() + timedelta(days=1)
)
class TestCronValidation:
"""Tests for cron expression validation."""
def test_validate_cron_valid_expressions(self, test_db):
"""Test validating various valid cron expressions."""
service = ScheduleService(test_db)
valid_expressions = [
'0 0 * * *', # Daily at midnight
'*/15 * * * *', # Every 15 minutes
'0 2 * * 0', # Weekly on Sunday
'0 0 1 * *', # Monthly
'30 14 * * 1-5', # Weekdays
'0 */4 * * *', # Every 4 hours
]
for expr in valid_expressions:
is_valid, error = service.validate_cron_expression(expr)
assert is_valid is True, f"Expression '{expr}' should be valid"
assert error is None
def test_validate_cron_invalid_expressions(self, test_db):
"""Test validating invalid cron expressions."""
service = ScheduleService(test_db)
invalid_expressions = [
'invalid',
'60 0 * * *', # Invalid minute (0-59)
'0 24 * * *', # Invalid hour (0-23)
'0 0 32 * *', # Invalid day (1-31)
'0 0 * 13 *', # Invalid month (1-12)
'0 0 * * 7', # Invalid weekday (0-6)
]
for expr in invalid_expressions:
is_valid, error = service.validate_cron_expression(expr)
assert is_valid is False, f"Expression '{expr}' should be invalid"
assert error is not None
class TestNextRunCalculation:
"""Tests for next run time calculation."""
def test_calculate_next_run(self, test_db):
"""Test calculating next run time."""
service = ScheduleService(test_db)
# Daily at 2 AM
next_run = service.calculate_next_run('0 2 * * *')
assert next_run is not None
assert isinstance(next_run, datetime)
assert next_run > datetime.utcnow()
def test_calculate_next_run_from_time(self, test_db):
"""Test calculating next run from specific time."""
service = ScheduleService(test_db)
base_time = datetime(2025, 1, 1, 0, 0, 0)
next_run = service.calculate_next_run('0 2 * * *', from_time=base_time)
# Should be 2 AM on same day
assert next_run.hour == 2
assert next_run.minute == 0
def test_calculate_next_run_invalid_cron(self, test_db):
"""Test calculating next run with invalid cron raises error."""
service = ScheduleService(test_db)
with pytest.raises(ValueError, match="Invalid cron expression"):
service.calculate_next_run('invalid cron')
class TestScheduleHistory:
"""Tests for schedule execution history."""
def test_get_schedule_history_empty(self, test_db, sample_config_file):
"""Test getting history for schedule with no executions."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
history = service.get_schedule_history(schedule_id)
assert len(history) == 0
def test_get_schedule_history_with_scans(self, test_db, sample_config_file):
"""Test getting history with multiple scans."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Create 15 scans
for i in range(15):
scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i),
status='completed',
config_file=sample_config_file,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
# Get history (default limit 10)
history = service.get_schedule_history(schedule_id, limit=10)
assert len(history) == 10
assert history[0]['title'] == 'Scan 0' # Most recent first
def test_get_schedule_history_custom_limit(self, test_db, sample_config_file):
"""Test getting history with custom limit."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
# Create 10 scans
for i in range(10):
scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i),
status='completed',
config_file=sample_config_file,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
# Get only 5
history = service.get_schedule_history(schedule_id, limit=5)
assert len(history) == 5
class TestScheduleSerialization:
"""Tests for schedule serialization."""
def test_schedule_to_dict(self, test_db, sample_config_file):
"""Test converting schedule to dictionary."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test Schedule',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
result = service.get_schedule(schedule_id)
# Verify all required fields
assert 'id' in result
assert 'name' in result
assert 'config_file' in result
assert 'cron_expression' in result
assert 'enabled' in result
assert 'last_run' in result
assert 'next_run' in result
assert 'next_run_relative' in result
assert 'created_at' in result
assert 'updated_at' in result
assert 'history' in result
def test_schedule_relative_time_formatting(self, test_db, sample_config_file):
"""Test relative time formatting in schedule dict."""
service = ScheduleService(test_db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
cron_expression='0 2 * * *',
enabled=True
)
result = service.get_schedule(schedule_id)
# Should have relative time for next_run
assert result['next_run_relative'] is not None
assert isinstance(result['next_run_relative'], str)
assert 'in' in result['next_run_relative'].lower()

325
app/tests/test_stats_api.py Normal file
View File

@@ -0,0 +1,325 @@
"""
Tests for stats API endpoints.
Tests dashboard statistics and trending data endpoints.
"""
import pytest
from datetime import datetime, timedelta
from web.models import Scan
class TestStatsAPI:
"""Test suite for stats API endpoints."""
def test_scan_trend_default_30_days(self, client, auth_headers, db_session):
"""Test scan trend endpoint with default 30 days."""
# Create test scans over multiple days
today = datetime.utcnow()
for i in range(5):
scan_date = today - timedelta(days=i)
for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=scan_date,
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
# Request trend data
response = client.get('/api/stats/scan-trend', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'labels' in data
assert 'values' in data
assert 'start_date' in data
assert 'end_date' in data
assert 'total_scans' in data
# Should have 30 days of data
assert len(data['labels']) == 30
assert len(data['values']) == 30
# Total scans should match (1+2+3+4+5 = 15)
assert data['total_scans'] == 15
# Values should be non-negative integers
assert all(isinstance(v, int) for v in data['values'])
assert all(v >= 0 for v in data['values'])
def test_scan_trend_custom_days(self, client, auth_headers, db_session):
"""Test scan trend endpoint with custom number of days."""
# Create test scans
today = datetime.utcnow()
for i in range(10):
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=today - timedelta(days=i),
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
# Request 7 days of data
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data['labels']) == 7
assert len(data['values']) == 7
assert data['total_scans'] == 7
def test_scan_trend_max_days_365(self, client, auth_headers):
"""Test scan trend endpoint accepts maximum 365 days."""
response = client.get('/api/stats/scan-trend?days=365', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data['labels']) == 365
def test_scan_trend_rejects_days_over_365(self, client, auth_headers):
"""Test scan trend endpoint rejects more than 365 days."""
response = client.get('/api/stats/scan-trend?days=366', headers=auth_headers)
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
assert '365' in data['error']
def test_scan_trend_rejects_days_less_than_1(self, client, auth_headers):
"""Test scan trend endpoint rejects days less than 1."""
response = client.get('/api/stats/scan-trend?days=0', headers=auth_headers)
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
def test_scan_trend_fills_missing_days_with_zero(self, client, auth_headers, db_session):
"""Test scan trend fills days with no scans as zero."""
# Create scans only on specific days
today = datetime.utcnow()
# Create scan 5 days ago
scan1 = Scan(
config_file='/app/configs/test.yaml',
timestamp=today - timedelta(days=5),
status='completed',
duration=10.5
)
db_session.add(scan1)
# Create scan 10 days ago
scan2 = Scan(
config_file='/app/configs/test.yaml',
timestamp=today - timedelta(days=10),
status='completed',
duration=10.5
)
db_session.add(scan2)
db_session.commit()
# Request 15 days
response = client.get('/api/stats/scan-trend?days=15', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should have 15 days of data
assert len(data['values']) == 15
# Most days should be zero
zero_days = sum(1 for v in data['values'] if v == 0)
assert zero_days >= 13 # At least 13 days with no scans
def test_scan_trend_requires_authentication(self, client):
"""Test scan trend endpoint requires authentication."""
response = client.get('/api/stats/scan-trend')
assert response.status_code == 401
def test_summary_endpoint(self, client, auth_headers, db_session):
"""Test summary statistics endpoint."""
# Create test scans with different statuses
today = datetime.utcnow()
# 5 completed scans
for i in range(5):
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=today - timedelta(days=i),
status='completed',
duration=10.5
)
db_session.add(scan)
# 2 failed scans
for i in range(2):
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=today - timedelta(days=i),
status='failed',
duration=5.0
)
db_session.add(scan)
# 1 running scan
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=today,
status='running',
duration=None
)
db_session.add(scan)
db_session.commit()
# Request summary
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'total_scans' in data
assert 'completed_scans' in data
assert 'failed_scans' in data
assert 'running_scans' in data
assert 'scans_today' in data
assert 'scans_this_week' in data
# Verify counts
assert data['total_scans'] == 8
assert data['completed_scans'] == 5
assert data['failed_scans'] == 2
assert data['running_scans'] == 1
assert data['scans_today'] >= 1
assert data['scans_this_week'] >= 1
def test_summary_with_no_scans(self, client, auth_headers):
"""Test summary endpoint with no scans."""
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['total_scans'] == 0
assert data['completed_scans'] == 0
assert data['failed_scans'] == 0
assert data['running_scans'] == 0
assert data['scans_today'] == 0
assert data['scans_this_week'] == 0
def test_summary_scans_today(self, client, auth_headers, db_session):
"""Test summary counts scans today correctly."""
today = datetime.utcnow()
yesterday = today - timedelta(days=1)
# Create 3 scans today
for i in range(3):
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=today,
status='completed',
duration=10.5
)
db_session.add(scan)
# Create 2 scans yesterday
for i in range(2):
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=yesterday,
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['scans_today'] == 3
assert data['scans_this_week'] >= 3
def test_summary_scans_this_week(self, client, auth_headers, db_session):
"""Test summary counts scans this week correctly."""
today = datetime.utcnow()
# Create scans over the last 10 days
for i in range(10):
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=today - timedelta(days=i),
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
response = client.get('/api/stats/summary', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Last 7 days (0-6) = 7 scans
assert data['scans_this_week'] == 7
def test_summary_requires_authentication(self, client):
"""Test summary endpoint requires authentication."""
response = client.get('/api/stats/summary')
assert response.status_code == 401
def test_scan_trend_date_format(self, client, auth_headers, db_session):
"""Test scan trend returns dates in correct format."""
# Create a scan
scan = Scan(
config_file='/app/configs/test.yaml',
timestamp=datetime.utcnow(),
status='completed',
duration=10.5
)
db_session.add(scan)
db_session.commit()
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Check date format (YYYY-MM-DD)
for label in data['labels']:
assert len(label) == 10
assert label[4] == '-'
assert label[7] == '-'
# Try parsing to ensure valid date
datetime.strptime(label, '%Y-%m-%d')
def test_scan_trend_consecutive_dates(self, client, auth_headers):
"""Test scan trend returns consecutive dates."""
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
labels = data['labels']
# Convert to datetime objects
dates = [datetime.strptime(label, '%Y-%m-%d') for label in labels]
# Check dates are consecutive
for i in range(len(dates) - 1):
diff = dates[i + 1] - dates[i]
assert diff.days == 1, f"Dates not consecutive: {dates[i]} to {dates[i+1]}"
def test_scan_trend_ends_with_today(self, client, auth_headers):
"""Test scan trend ends with today's date."""
response = client.get('/api/stats/scan-trend?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Last date should be today
today = datetime.utcnow().date()
last_date = datetime.strptime(data['labels'][-1], '%Y-%m-%d').date()
assert last_date == today

197
app/validate_phase1.py Executable file
View File

@@ -0,0 +1,197 @@
#!/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())

0
app/web/__init__.py Normal file
View File

0
app/web/api/__init__.py Normal file
View File

144
app/web/api/alerts.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Alerts API blueprint.
Handles endpoints for viewing alert history and managing alert rules.
"""
from flask import Blueprint, jsonify, request
from web.auth.decorators import api_auth_required
bp = Blueprint('alerts', __name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_alerts():
"""
List recent alerts.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20)
alert_type: Filter by alert type
severity: Filter by severity (info, warning, critical)
start_date: Filter alerts after this date
end_date: Filter alerts before this date
Returns:
JSON response with alerts list
"""
# TODO: Implement in Phase 4
return jsonify({
'alerts': [],
'total': 0,
'page': 1,
'per_page': 20,
'message': 'Alerts list endpoint - to be implemented in Phase 4'
})
@bp.route('/rules', methods=['GET'])
@api_auth_required
def list_alert_rules():
"""
List all alert rules.
Returns:
JSON response with alert rules
"""
# TODO: Implement in Phase 4
return jsonify({
'rules': [],
'message': 'Alert rules list endpoint - to be implemented in Phase 4'
})
@bp.route('/rules', methods=['POST'])
@api_auth_required
def create_alert_rule():
"""
Create a new alert rule.
Request body:
rule_type: Type of alert rule
threshold: Threshold value (e.g., days for cert expiry)
enabled: Whether rule is active (default: true)
email_enabled: Send email for this rule (default: false)
Returns:
JSON response with created rule ID
"""
# TODO: Implement in Phase 4
data = request.get_json() or {}
return jsonify({
'rule_id': None,
'status': 'not_implemented',
'message': 'Alert rule creation endpoint - to be implemented in Phase 4',
'data': data
}), 501
@bp.route('/rules/<int:rule_id>', methods=['PUT'])
@api_auth_required
def update_alert_rule(rule_id):
"""
Update an existing alert rule.
Args:
rule_id: Alert rule ID to update
Request body:
threshold: Threshold value (optional)
enabled: Whether rule is active (optional)
email_enabled: Send email for this rule (optional)
Returns:
JSON response with update status
"""
# TODO: Implement in Phase 4
data = request.get_json() or {}
return jsonify({
'rule_id': rule_id,
'status': 'not_implemented',
'message': 'Alert rule update endpoint - to be implemented in Phase 4',
'data': data
}), 501
@bp.route('/rules/<int:rule_id>', methods=['DELETE'])
@api_auth_required
def delete_alert_rule(rule_id):
"""
Delete an alert rule.
Args:
rule_id: Alert rule ID to delete
Returns:
JSON response with deletion status
"""
# TODO: Implement in Phase 4
return jsonify({
'rule_id': rule_id,
'status': 'not_implemented',
'message': 'Alert rule deletion endpoint - to be implemented in Phase 4'
}), 501
# Health check endpoint
@bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for monitoring.
Returns:
JSON response with API health status
"""
return jsonify({
'status': 'healthy',
'api': 'alerts',
'version': '1.0.0-phase1'
})

452
app/web/api/configs.py Normal file
View File

@@ -0,0 +1,452 @@
"""
Configs API blueprint.
Handles endpoints for managing scan configuration files, including CSV/YAML upload,
template download, and config management.
"""
import logging
import io
from flask import Blueprint, jsonify, request, send_file
from werkzeug.utils import secure_filename
from web.auth.decorators import api_auth_required
from web.services.config_service import ConfigService
bp = Blueprint('configs', __name__)
logger = logging.getLogger(__name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_configs():
"""
List all config files with metadata.
Returns:
JSON response with list of configs:
{
"configs": [
{
"filename": "prod-scan.yaml",
"title": "Prod Scan",
"path": "/app/configs/prod-scan.yaml",
"created_at": "2025-11-15T10:30:00Z",
"size_bytes": 1234,
"used_by_schedules": ["Daily Scan"]
}
]
}
"""
try:
config_service = ConfigService()
configs = config_service.list_configs()
logger.info(f"Listed {len(configs)} config files")
return jsonify({
'configs': configs
})
except Exception as e:
logger.error(f"Unexpected error listing configs: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>', methods=['GET'])
@api_auth_required
def get_config(filename: str):
"""
Get config file content and parsed data.
Args:
filename: Config filename
Returns:
JSON response with config content:
{
"filename": "prod-scan.yaml",
"content": "title: Prod Scan\n...",
"parsed": {"title": "Prod Scan", "sites": [...]}
}
"""
try:
# Sanitize filename
filename = secure_filename(filename)
config_service = ConfigService()
config_data = config_service.get_config(filename)
logger.info(f"Retrieved config file: {filename}")
return jsonify(config_data)
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except ValueError as e:
logger.warning(f"Invalid config file: {filename} - {str(e)}")
return jsonify({
'error': 'Invalid config',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error getting config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/create-from-cidr', methods=['POST'])
@api_auth_required
def create_from_cidr():
"""
Create config from CIDR range.
Request:
JSON with:
{
"title": "My Scan",
"cidr": "10.0.0.0/24",
"site_name": "Production" (optional),
"ping_default": false (optional)
}
Returns:
JSON response with created config info:
{
"success": true,
"filename": "my-scan.yaml",
"preview": "title: My Scan\n..."
}
"""
try:
data = request.get_json()
if not data:
return jsonify({
'error': 'Bad request',
'message': 'Request body must be JSON'
}), 400
# Validate required fields
if 'title' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: title'
}), 400
if 'cidr' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: cidr'
}), 400
title = data['title']
cidr = data['cidr']
site_name = data.get('site_name', None)
ping_default = data.get('ping_default', False)
# Validate title
if not title or not title.strip():
return jsonify({
'error': 'Validation error',
'message': 'Title cannot be empty'
}), 400
# Create config from CIDR
config_service = ConfigService()
filename, yaml_preview = config_service.create_from_cidr(
title=title,
cidr=cidr,
site_name=site_name,
ping_default=ping_default
)
logger.info(f"Created config from CIDR {cidr}: {filename}")
return jsonify({
'success': True,
'filename': filename,
'preview': yaml_preview
})
except ValueError as e:
logger.warning(f"CIDR validation failed: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error creating config from CIDR: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/upload-yaml', methods=['POST'])
@api_auth_required
def upload_yaml():
"""
Upload YAML config file directly.
Request:
multipart/form-data with 'file' field containing YAML file
Optional 'filename' field for custom filename
Returns:
JSON response with created config info:
{
"success": true,
"filename": "prod-scan.yaml"
}
"""
try:
# Check if file is present
if 'file' not in request.files:
return jsonify({
'error': 'Bad request',
'message': 'No file provided'
}), 400
file = request.files['file']
# Check if file is selected
if file.filename == '':
return jsonify({
'error': 'Bad request',
'message': 'No file selected'
}), 400
# Check file extension
if not (file.filename.endswith('.yaml') or file.filename.endswith('.yml')):
return jsonify({
'error': 'Bad request',
'message': 'File must be a YAML file (.yaml or .yml extension)'
}), 400
# Read YAML content
yaml_content = file.read().decode('utf-8')
# Get filename (use uploaded filename or custom)
filename = request.form.get('filename', file.filename)
filename = secure_filename(filename)
# Create config from YAML
config_service = ConfigService()
final_filename = config_service.create_from_yaml(filename, yaml_content)
logger.info(f"Created config from YAML upload: {final_filename}")
return jsonify({
'success': True,
'filename': final_filename
})
except ValueError as e:
logger.warning(f"YAML validation failed: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except UnicodeDecodeError:
logger.warning("YAML file encoding error")
return jsonify({
'error': 'Encoding error',
'message': 'YAML file must be UTF-8 encoded'
}), 400
except Exception as e:
logger.error(f"Unexpected error uploading YAML: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>/download', methods=['GET'])
@api_auth_required
def download_config(filename: str):
"""
Download existing config file.
Args:
filename: Config filename
Returns:
YAML file download
"""
try:
# Sanitize filename
filename = secure_filename(filename)
config_service = ConfigService()
config_data = config_service.get_config(filename)
# Create file-like object
yaml_file = io.BytesIO(config_data['content'].encode('utf-8'))
yaml_file.seek(0)
logger.info(f"Config file downloaded: {filename}")
# Send file
return send_file(
yaml_file,
mimetype='application/x-yaml',
as_attachment=True,
download_name=filename
)
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except Exception as e:
logger.error(f"Unexpected error downloading config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>', methods=['PUT'])
@api_auth_required
def update_config(filename: str):
"""
Update existing config file with new YAML content.
Args:
filename: Config filename
Request:
JSON with:
{
"content": "title: My Scan\nsites: ..."
}
Returns:
JSON response with success status:
{
"success": true,
"message": "Config updated successfully"
}
Error responses:
- 400: Invalid YAML or config structure
- 404: Config file not found
- 500: Internal server error
"""
try:
# Sanitize filename
filename = secure_filename(filename)
data = request.get_json()
if not data or 'content' not in data:
return jsonify({
'error': 'Bad request',
'message': 'Missing required field: content'
}), 400
yaml_content = data['content']
# Update config
config_service = ConfigService()
config_service.update_config(filename, yaml_content)
logger.info(f"Updated config file: {filename}")
return jsonify({
'success': True,
'message': 'Config updated successfully'
})
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except ValueError as e:
logger.warning(f"Invalid config content for {filename}: {str(e)}")
return jsonify({
'error': 'Validation error',
'message': str(e)
}), 400
except Exception as e:
logger.error(f"Unexpected error updating config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<filename>', methods=['DELETE'])
@api_auth_required
def delete_config(filename: str):
"""
Delete config file and cascade delete associated schedules.
When a config is deleted, all schedules using that config (both enabled
and disabled) are automatically deleted as well.
Args:
filename: Config filename
Returns:
JSON response with success status:
{
"success": true,
"message": "Config deleted successfully"
}
Error responses:
- 404: Config file not found
- 500: Internal server error
"""
try:
# Sanitize filename
filename = secure_filename(filename)
config_service = ConfigService()
config_service.delete_config(filename)
logger.info(f"Deleted config file: {filename}")
return jsonify({
'success': True,
'message': 'Config deleted successfully'
})
except FileNotFoundError as e:
logger.warning(f"Config file not found: {filename}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except Exception as e:
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500

338
app/web/api/scans.py Normal file
View File

@@ -0,0 +1,338 @@
"""
Scans API blueprint.
Handles endpoints for triggering scans, listing scan history, and retrieving
scan results.
"""
import logging
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required
from web.services.scan_service import ScanService
from web.utils.validators import validate_config_file
from web.utils.pagination import validate_page_params
bp = Blueprint('scans', __name__)
logger = logging.getLogger(__name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_scans():
"""
List all scans with pagination.
Query params:
page: Page number (default: 1)
per_page: Items per page (default: 20, max: 100)
status: Filter by status (running, completed, failed)
Returns:
JSON response with scans list and pagination info
"""
try:
# Get and validate query parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
status_filter = request.args.get('status', None, type=str)
# Validate pagination params
page, per_page = validate_page_params(page, per_page)
# Get scans from service
scan_service = ScanService(current_app.db_session)
paginated_result = scan_service.list_scans(
page=page,
per_page=per_page,
status_filter=status_filter
)
logger.info(f"Listed scans: page={page}, per_page={per_page}, status={status_filter}, total={paginated_result.total}")
return jsonify({
'scans': paginated_result.items,
'total': paginated_result.total,
'page': paginated_result.page,
'per_page': paginated_result.per_page,
'total_pages': paginated_result.pages,
'has_prev': paginated_result.has_prev,
'has_next': paginated_result.has_next
})
except ValueError as e:
logger.warning(f"Invalid request parameters: {str(e)}")
return jsonify({
'error': 'Invalid request',
'message': str(e)
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error listing scans: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scans'
}), 500
except Exception as e:
logger.error(f"Unexpected error listing scans: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>', methods=['GET'])
@api_auth_required
def get_scan(scan_id):
"""
Get details for a specific scan.
Args:
scan_id: Scan ID
Returns:
JSON response with scan details
"""
try:
# Get scan from service
scan_service = ScanService(current_app.db_session)
scan = scan_service.get_scan(scan_id)
if not scan:
logger.warning(f"Scan not found: {scan_id}")
return jsonify({
'error': 'Not found',
'message': f'Scan with ID {scan_id} not found'
}), 404
logger.info(f"Retrieved scan details: {scan_id}")
return jsonify(scan)
except SQLAlchemyError as e:
logger.error(f"Database error retrieving scan {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scan'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving scan {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('', methods=['POST'])
@api_auth_required
def trigger_scan():
"""
Trigger a new scan.
Request body:
config_file: Path to YAML config file
Returns:
JSON response with scan_id and status
"""
try:
# Get request data
data = request.get_json() or {}
config_file = data.get('config_file')
# Validate required fields
if not config_file:
logger.warning("Scan trigger request missing config_file")
return jsonify({
'error': 'Invalid request',
'message': 'config_file is required'
}), 400
# Trigger scan via service
scan_service = ScanService(current_app.db_session)
scan_id = scan_service.trigger_scan(
config_file=config_file,
triggered_by='api',
scheduler=current_app.scheduler
)
logger.info(f"Scan {scan_id} triggered via API: config={config_file}")
return jsonify({
'scan_id': scan_id,
'status': 'running',
'message': 'Scan queued successfully'
}), 201
except ValueError as e:
# Config file validation error
error_message = str(e)
logger.warning(f"Invalid config file: {error_message}")
logger.warning(f"Request data: config_file='{config_file}'")
return jsonify({
'error': 'Invalid request',
'message': error_message
}), 400
except SQLAlchemyError as e:
logger.error(f"Database error triggering scan: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to create scan'
}), 500
except Exception as e:
logger.error(f"Unexpected error triggering scan: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>', methods=['DELETE'])
@api_auth_required
def delete_scan(scan_id):
"""
Delete a scan and its associated files.
Args:
scan_id: Scan ID to delete
Returns:
JSON response with deletion status
"""
try:
# Delete scan via service
scan_service = ScanService(current_app.db_session)
scan_service.delete_scan(scan_id)
logger.info(f"Scan {scan_id} deleted successfully")
return jsonify({
'scan_id': scan_id,
'message': 'Scan deleted successfully'
}), 200
except ValueError as e:
# Scan not found
logger.warning(f"Scan deletion failed: {str(e)}")
return jsonify({
'error': 'Not found',
'message': str(e)
}), 404
except SQLAlchemyError as e:
logger.error(f"Database error deleting scan {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to delete scan'
}), 500
except Exception as e:
logger.error(f"Unexpected error deleting scan {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id>/status', methods=['GET'])
@api_auth_required
def get_scan_status(scan_id):
"""
Get current status of a running scan.
Args:
scan_id: Scan ID
Returns:
JSON response with scan status and progress
"""
try:
# Get scan status from service
scan_service = ScanService(current_app.db_session)
status = scan_service.get_scan_status(scan_id)
if not status:
logger.warning(f"Scan not found for status check: {scan_id}")
return jsonify({
'error': 'Not found',
'message': f'Scan with ID {scan_id} not found'
}), 404
logger.debug(f"Retrieved status for scan {scan_id}: {status['status']}")
return jsonify(status)
except SQLAlchemyError as e:
logger.error(f"Database error retrieving scan status {scan_id}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to retrieve scan status'
}), 500
except Exception as e:
logger.error(f"Unexpected error retrieving scan status {scan_id}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 500
@bp.route('/<int:scan_id1>/compare/<int:scan_id2>', methods=['GET'])
@api_auth_required
def compare_scans(scan_id1, scan_id2):
"""
Compare two scans and show differences.
Compares ports, services, and certificates between two scans,
highlighting added, removed, and changed items.
Args:
scan_id1: First (older) scan ID
scan_id2: Second (newer) scan ID
Returns:
JSON response with comparison results including:
- scan1, scan2: Metadata for both scans
- ports: Added, removed, and unchanged ports
- services: Added, removed, and changed services
- certificates: Added, removed, and changed certificates
- drift_score: Overall drift metric (0.0-1.0)
"""
try:
# Compare scans using service
scan_service = ScanService(current_app.db_session)
comparison = scan_service.compare_scans(scan_id1, scan_id2)
if not comparison:
logger.warning(f"Scan comparison failed: one or both scans not found ({scan_id1}, {scan_id2})")
return jsonify({
'error': 'Not found',
'message': 'One or both scans not found'
}), 404
logger.info(f"Compared scans {scan_id1} and {scan_id2}: drift_score={comparison['drift_score']}")
return jsonify(comparison), 200
except SQLAlchemyError as e:
logger.error(f"Database error comparing scans {scan_id1} and {scan_id2}: {str(e)}")
return jsonify({
'error': 'Database error',
'message': 'Failed to compare scans'
}), 500
except Exception as e:
logger.error(f"Unexpected error comparing scans {scan_id1} and {scan_id2}: {str(e)}", exc_info=True)
return jsonify({
'error': 'Internal server error',
'message': 'An unexpected error occurred'
}), 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': 'scans',
'version': '1.0.0-phase1'
})

331
app/web/api/schedules.py Normal file
View File

@@ -0,0 +1,331 @@
"""
Schedules API blueprint.
Handles endpoints for managing scheduled scans including CRUD operations
and manual triggering.
"""
import logging
from flask import Blueprint, jsonify, request, current_app
from web.auth.decorators import api_auth_required
from web.services.schedule_service import ScheduleService
from web.services.scan_service import ScanService
logger = logging.getLogger(__name__)
bp = Blueprint('schedules', __name__)
@bp.route('', methods=['GET'])
@api_auth_required
def list_schedules():
"""
List all schedules with pagination and filtering.
Query parameters:
page: Page number (default: 1)
per_page: Items per page (default: 20)
enabled: Filter by enabled status (true/false)
Returns:
JSON response with paginated schedules list
"""
try:
# Parse query parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
enabled_str = request.args.get('enabled', type=str)
# Parse enabled filter
enabled_filter = None
if enabled_str is not None:
enabled_filter = enabled_str.lower() == 'true'
# Get schedules
schedule_service = ScheduleService(current_app.db_session)
result = schedule_service.list_schedules(page, per_page, enabled_filter)
return jsonify(result), 200
except Exception as e:
logger.error(f"Error listing schedules: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['GET'])
@api_auth_required
def get_schedule(schedule_id):
"""
Get details for a specific schedule.
Args:
schedule_id: Schedule ID
Returns:
JSON response with schedule details including execution history
"""
try:
schedule_service = ScheduleService(current_app.db_session)
schedule = schedule_service.get_schedule(schedule_id)
return jsonify(schedule), 200
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error getting schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('', methods=['POST'])
@api_auth_required
def create_schedule():
"""
Create a new schedule.
Request body:
name: Schedule name (required)
config_file: Path to YAML config (required)
cron_expression: Cron expression (required, e.g., '0 2 * * *')
enabled: Whether schedule is active (optional, default: true)
Returns:
JSON response with created schedule ID
"""
try:
data = request.get_json() or {}
# Validate required fields
required = ['name', 'config_file', 'cron_expression']
missing = [field for field in required if field not in data]
if missing:
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
# Create schedule
schedule_service = ScheduleService(current_app.db_session)
schedule_id = schedule_service.create_schedule(
name=data['name'],
config_file=data['config_file'],
cron_expression=data['cron_expression'],
enabled=data.get('enabled', True)
)
# Get the created schedule
schedule = schedule_service.get_schedule(schedule_id)
# Add to APScheduler if enabled
if schedule['enabled'] and hasattr(current_app, 'scheduler'):
try:
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=schedule['config_file'],
cron_expression=schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} added to APScheduler")
except Exception as e:
logger.error(f"Failed to add schedule {schedule_id} to APScheduler: {str(e)}")
# Continue anyway - schedule is created in DB
return jsonify({
'schedule_id': schedule_id,
'message': 'Schedule created successfully',
'schedule': schedule
}), 201
except ValueError as e:
# Validation error
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error(f"Error creating schedule: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['PUT'])
@api_auth_required
def update_schedule(schedule_id):
"""
Update an existing schedule.
Args:
schedule_id: Schedule ID to update
Request body:
name: Schedule name (optional)
config_file: Path to YAML config (optional)
cron_expression: Cron expression (optional)
enabled: Whether schedule is active (optional)
Returns:
JSON response with updated schedule
"""
try:
data = request.get_json() or {}
if not data:
return jsonify({'error': 'No update data provided'}), 400
# Update schedule
schedule_service = ScheduleService(current_app.db_session)
# Store old state to check if scheduler update needed
old_schedule = schedule_service.get_schedule(schedule_id)
# Perform update
updated_schedule = schedule_service.update_schedule(schedule_id, **data)
# Update in APScheduler if needed
if hasattr(current_app, 'scheduler'):
try:
# If cron expression or config changed, or enabled status changed
cron_changed = 'cron_expression' in data
config_changed = 'config_file' in data
enabled_changed = 'enabled' in data
if enabled_changed:
if updated_schedule['enabled']:
# Re-add to scheduler (replaces existing)
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=updated_schedule['config_file'],
cron_expression=updated_schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
else:
# Remove from scheduler
current_app.scheduler.remove_scheduled_scan(schedule_id)
logger.info(f"Schedule {schedule_id} disabled and removed from APScheduler")
elif (cron_changed or config_changed) and updated_schedule['enabled']:
# Reload schedule in APScheduler
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=updated_schedule['config_file'],
cron_expression=updated_schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} reloaded in APScheduler")
except Exception as e:
logger.error(f"Failed to update schedule {schedule_id} in APScheduler: {str(e)}")
# Continue anyway - schedule is updated in DB
return jsonify({
'message': 'Schedule updated successfully',
'schedule': updated_schedule
}), 200
except ValueError as e:
# Schedule not found or validation error
if 'not found' in str(e):
return jsonify({'error': str(e)}), 404
return jsonify({'error': str(e)}), 400
except Exception as e:
logger.error(f"Error updating schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>', methods=['DELETE'])
@api_auth_required
def delete_schedule(schedule_id):
"""
Delete a schedule.
Note: Associated scans are NOT deleted (schedule_id becomes null).
Active scans will complete normally.
Args:
schedule_id: Schedule ID to delete
Returns:
JSON response with deletion status
"""
try:
# Remove from APScheduler first
if hasattr(current_app, 'scheduler'):
try:
current_app.scheduler.remove_scheduled_scan(schedule_id)
logger.info(f"Schedule {schedule_id} removed from APScheduler")
except Exception as e:
logger.warning(f"Failed to remove schedule {schedule_id} from APScheduler: {str(e)}")
# Continue anyway
# Delete from database
schedule_service = ScheduleService(current_app.db_session)
schedule_service.delete_schedule(schedule_id)
return jsonify({
'message': 'Schedule deleted successfully',
'schedule_id': schedule_id
}), 200
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error deleting schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 500
@bp.route('/<int:schedule_id>/trigger', methods=['POST'])
@api_auth_required
def trigger_schedule(schedule_id):
"""
Manually trigger a scheduled scan.
Creates a new scan with the schedule's configuration and queues it
for immediate execution.
Args:
schedule_id: Schedule ID to trigger
Returns:
JSON response with triggered scan ID
"""
try:
# Get schedule
schedule_service = ScheduleService(current_app.db_session)
schedule = schedule_service.get_schedule(schedule_id)
# Trigger scan
scan_service = ScanService(current_app.db_session)
# Get scheduler if available
scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None
scan_id = scan_service.trigger_scan(
config_file=schedule['config_file'],
triggered_by='manual',
schedule_id=schedule_id,
scheduler=scheduler
)
logger.info(f"Manual trigger of schedule {schedule_id} created scan {scan_id}")
return jsonify({
'message': 'Scan triggered successfully',
'schedule_id': schedule_id,
'scan_id': scan_id
}), 201
except ValueError as e:
# Schedule not found
return jsonify({'error': str(e)}), 404
except Exception as e:
logger.error(f"Error triggering schedule {schedule_id}: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error'}), 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': 'schedules',
'version': '1.0.0-phase1'
})

271
app/web/api/settings.py Normal file
View File

@@ -0,0 +1,271 @@
"""
Settings API blueprint.
Handles endpoints for managing application settings including SMTP configuration,
authentication, and system preferences.
"""
from flask import Blueprint, current_app, jsonify, request
from web.auth.decorators import api_auth_required
from web.utils.settings import PasswordManager, SettingsManager
bp = Blueprint('settings', __name__)
def get_settings_manager():
"""Get SettingsManager instance with current DB session."""
return SettingsManager(current_app.db_session)
@bp.route('', methods=['GET'])
@api_auth_required
def get_settings():
"""
Get all settings (sanitized - encrypted values masked).
Returns:
JSON response with all settings
"""
try:
settings_manager = get_settings_manager()
settings = settings_manager.get_all(decrypt=False, sanitize=True)
return jsonify({
'status': 'success',
'settings': settings
})
except Exception as e:
current_app.logger.error(f"Failed to retrieve settings: {e}")
return jsonify({
'status': 'error',
'message': 'Failed to retrieve settings'
}), 500
@bp.route('', methods=['PUT'])
@api_auth_required
def update_settings():
"""
Update multiple settings at once.
Request body:
settings: Dictionary of setting key-value pairs
Returns:
JSON response with update status
"""
data = request.get_json() or {}
settings_dict = data.get('settings', {})
if not settings_dict:
return jsonify({
'status': 'error',
'message': 'No settings provided'
}), 400
try:
settings_manager = get_settings_manager()
# Update each setting
for key, value in settings_dict.items():
settings_manager.set(key, value)
return jsonify({
'status': 'success',
'message': f'Updated {len(settings_dict)} settings'
})
except Exception as e:
current_app.logger.error(f"Failed to update settings: {e}")
return jsonify({
'status': 'error',
'message': 'Failed to update settings'
}), 500
@bp.route('/<string:key>', methods=['GET'])
@api_auth_required
def get_setting(key):
"""
Get a specific setting by key.
Args:
key: Setting key
Returns:
JSON response with setting value
"""
try:
settings_manager = get_settings_manager()
value = settings_manager.get(key)
if value is None:
return jsonify({
'status': 'error',
'message': f'Setting "{key}" not found'
}), 404
# Sanitize if encrypted key
if settings_manager._should_encrypt(key):
value = '***ENCRYPTED***'
return jsonify({
'status': 'success',
'key': key,
'value': value
})
except Exception as e:
current_app.logger.error(f"Failed to retrieve setting {key}: {e}")
return jsonify({
'status': 'error',
'message': 'Failed to retrieve setting'
}), 500
@bp.route('/<string:key>', methods=['PUT'])
@api_auth_required
def update_setting(key):
"""
Update a specific setting.
Args:
key: Setting key
Request body:
value: New value for the setting
Returns:
JSON response with update status
"""
data = request.get_json() or {}
value = data.get('value')
if value is None:
return jsonify({
'status': 'error',
'message': 'No value provided'
}), 400
try:
settings_manager = get_settings_manager()
settings_manager.set(key, value)
return jsonify({
'status': 'success',
'message': f'Setting "{key}" updated'
})
except Exception as e:
current_app.logger.error(f"Failed to update setting {key}: {e}")
return jsonify({
'status': 'error',
'message': 'Failed to update setting'
}), 500
@bp.route('/<string:key>', methods=['DELETE'])
@api_auth_required
def delete_setting(key):
"""
Delete a setting.
Args:
key: Setting key to delete
Returns:
JSON response with deletion status
"""
try:
settings_manager = get_settings_manager()
deleted = settings_manager.delete(key)
if not deleted:
return jsonify({
'status': 'error',
'message': f'Setting "{key}" not found'
}), 404
return jsonify({
'status': 'success',
'message': f'Setting "{key}" deleted'
})
except Exception as e:
current_app.logger.error(f"Failed to delete setting {key}: {e}")
return jsonify({
'status': 'error',
'message': 'Failed to delete setting'
}), 500
@bp.route('/password', methods=['POST'])
@api_auth_required
def set_password():
"""
Set the application password.
Request body:
password: New password
Returns:
JSON response with status
"""
data = request.get_json() or {}
password = data.get('password')
if not password:
return jsonify({
'status': 'error',
'message': 'No password provided'
}), 400
if len(password) < 8:
return jsonify({
'status': 'error',
'message': 'Password must be at least 8 characters'
}), 400
try:
settings_manager = get_settings_manager()
PasswordManager.set_app_password(settings_manager, password)
return jsonify({
'status': 'success',
'message': 'Password updated successfully'
})
except Exception as e:
current_app.logger.error(f"Failed to set password: {e}")
return jsonify({
'status': 'error',
'message': 'Failed to set password'
}), 500
@bp.route('/test-email', methods=['POST'])
@api_auth_required
def test_email():
"""
Test email configuration by sending a test email.
Returns:
JSON response with test result
"""
# TODO: Implement in Phase 4 (email support)
return jsonify({
'status': 'not_implemented',
'message': 'Email testing endpoint - to be implemented in Phase 4'
}), 501
# 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': 'settings',
'version': '1.0.0-phase1'
})

258
app/web/api/stats.py Normal file
View File

@@ -0,0 +1,258 @@
"""
Stats API blueprint.
Handles endpoints for dashboard statistics, trending data, and analytics.
"""
import logging
from datetime import datetime, timedelta
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy import func, Date
from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required
from web.models import Scan
bp = Blueprint('stats', __name__)
logger = logging.getLogger(__name__)
@bp.route('/scan-trend', methods=['GET'])
@api_auth_required
def scan_trend():
"""
Get scan activity trend data for charts.
Query params:
days: Number of days to include (default: 30, max: 365)
Returns:
JSON response with labels and values arrays for Chart.js
{
"labels": ["2025-01-01", "2025-01-02", ...],
"values": [5, 3, 7, 2, ...]
}
"""
try:
# Get and validate query parameters
days = request.args.get('days', 30, type=int)
# Validate days parameter
if days < 1:
return jsonify({'error': 'days parameter must be at least 1'}), 400
if days > 365:
return jsonify({'error': 'days parameter cannot exceed 365'}), 400
# Calculate date range
end_date = datetime.utcnow().date()
start_date = end_date - timedelta(days=days - 1)
# Query scan counts per day
db_session = current_app.db_session
scan_counts = (
db_session.query(
func.date(Scan.timestamp).label('scan_date'),
func.count(Scan.id).label('scan_count')
)
.filter(func.date(Scan.timestamp) >= start_date)
.filter(func.date(Scan.timestamp) <= end_date)
.group_by(func.date(Scan.timestamp))
.order_by('scan_date')
.all()
)
# Create a dictionary of date -> count
scan_dict = {str(row.scan_date): row.scan_count for row in scan_counts}
# Generate all dates in range (fill missing dates with 0)
labels = []
values = []
current_date = start_date
while current_date <= end_date:
date_str = str(current_date)
labels.append(date_str)
values.append(scan_dict.get(date_str, 0))
current_date += timedelta(days=1)
return jsonify({
'labels': labels,
'values': values,
'start_date': str(start_date),
'end_date': str(end_date),
'total_scans': sum(values)
}), 200
except SQLAlchemyError as e:
logger.error(f"Database error in scan_trend: {str(e)}")
return jsonify({'error': 'Database error occurred'}), 500
except Exception as e:
logger.error(f"Error in scan_trend: {str(e)}")
return jsonify({'error': 'An error occurred'}), 500
@bp.route('/summary', methods=['GET'])
@api_auth_required
def summary():
"""
Get dashboard summary statistics.
Returns:
JSON response with summary stats
{
"total_scans": 150,
"completed_scans": 140,
"failed_scans": 5,
"running_scans": 5,
"scans_today": 3,
"scans_this_week": 15
}
"""
try:
db_session = current_app.db_session
# Get total counts by status
total_scans = db_session.query(func.count(Scan.id)).scalar() or 0
completed_scans = db_session.query(func.count(Scan.id)).filter(
Scan.status == 'completed'
).scalar() or 0
failed_scans = db_session.query(func.count(Scan.id)).filter(
Scan.status == 'failed'
).scalar() or 0
running_scans = db_session.query(func.count(Scan.id)).filter(
Scan.status == 'running'
).scalar() or 0
# Get scans today
today = datetime.utcnow().date()
scans_today = db_session.query(func.count(Scan.id)).filter(
func.date(Scan.timestamp) == today
).scalar() or 0
# Get scans this week (last 7 days)
week_ago = today - timedelta(days=6)
scans_this_week = db_session.query(func.count(Scan.id)).filter(
func.date(Scan.timestamp) >= week_ago
).scalar() or 0
return jsonify({
'total_scans': total_scans,
'completed_scans': completed_scans,
'failed_scans': failed_scans,
'running_scans': running_scans,
'scans_today': scans_today,
'scans_this_week': scans_this_week
}), 200
except SQLAlchemyError as e:
logger.error(f"Database error in summary: {str(e)}")
return jsonify({'error': 'Database error occurred'}), 500
except Exception as e:
logger.error(f"Error in summary: {str(e)}")
return jsonify({'error': 'An error occurred'}), 500
@bp.route('/scan-history/<int:scan_id>', methods=['GET'])
@api_auth_required
def scan_history(scan_id):
"""
Get historical trend data for scans with the same config file.
Returns port counts and other metrics over time for the same
configuration/target as the specified scan.
Args:
scan_id: Reference scan ID
Query params:
limit: Maximum number of historical scans to include (default: 10, max: 50)
Returns:
JSON response with historical scan data
{
"scans": [
{
"id": 123,
"timestamp": "2025-01-01T12:00:00",
"title": "Scan title",
"port_count": 25,
"ip_count": 5
},
...
],
"labels": ["2025-01-01", ...],
"port_counts": [25, 26, 24, ...]
}
"""
try:
# Get query parameters
limit = request.args.get('limit', 10, type=int)
if limit > 50:
limit = 50
db_session = current_app.db_session
# Get the reference scan to find its config file
from web.models import ScanPort
reference_scan = db_session.query(Scan).filter(Scan.id == scan_id).first()
if not reference_scan:
return jsonify({'error': 'Scan not found'}), 404
config_file = reference_scan.config_file
# Query historical scans with the same config file
historical_scans = (
db_session.query(Scan)
.filter(Scan.config_file == config_file)
.filter(Scan.status == 'completed')
.order_by(Scan.timestamp.desc())
.limit(limit)
.all()
)
# Build result data
scans_data = []
labels = []
port_counts = []
for scan in reversed(historical_scans): # Reverse to get chronological order
# Count ports for this scan
port_count = (
db_session.query(func.count(ScanPort.id))
.filter(ScanPort.scan_id == scan.id)
.scalar() or 0
)
# Count unique IPs for this scan
from web.models import ScanIP
ip_count = (
db_session.query(func.count(ScanIP.id))
.filter(ScanIP.scan_id == scan.id)
.scalar() or 0
)
scans_data.append({
'id': scan.id,
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
'title': scan.title,
'port_count': port_count,
'ip_count': ip_count
})
# For chart data
labels.append(scan.timestamp.strftime('%Y-%m-%d %H:%M') if scan.timestamp else '')
port_counts.append(port_count)
return jsonify({
'scans': scans_data,
'labels': labels,
'port_counts': port_counts,
'config_file': config_file
}), 200
except SQLAlchemyError as e:
logger.error(f"Database error in scan_history: {str(e)}")
return jsonify({'error': 'Database error occurred'}), 500
except Exception as e:
logger.error(f"Error in scan_history: {str(e)}")
return jsonify({'error': 'An error occurred'}), 500

599
app/web/app.py Normal file
View File

@@ -0,0 +1,599 @@
"""
Flask application factory for SneakyScanner web interface.
This module creates and configures the Flask application with all necessary
extensions, blueprints, and middleware.
"""
import logging
import os
import uuid
from logging.handlers import RotatingFileHandler
from pathlib import Path
from flask import Flask, g, jsonify, request
from flask_cors import CORS
from flask_login import LoginManager, current_user
from sqlalchemy import create_engine, event
from sqlalchemy.orm import scoped_session, sessionmaker
from web.models import Base
class RequestIDLogFilter(logging.Filter):
"""
Logging filter that injects request ID into log records.
Adds a 'request_id' attribute to each log record. For requests within
Flask request context, uses the request ID from g.request_id. For logs
outside request context (background jobs, startup), uses 'system'.
"""
def filter(self, record):
"""Add request_id to log record."""
try:
# Try to get request ID from Flask's g object
record.request_id = g.get('request_id', 'system')
except (RuntimeError, AttributeError):
# Outside of request context
record.request_id = 'system'
return True
def create_app(config: dict = None) -> Flask:
"""
Create and configure the Flask application.
Args:
config: Optional configuration dictionary to override defaults
Returns:
Configured Flask application instance
"""
app = Flask(__name__,
instance_relative_config=True,
static_folder='static',
template_folder='templates')
# Load default configuration
app.config.from_mapping(
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production'),
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///./sneakyscanner.db'),
SQLALCHEMY_TRACK_MODIFICATIONS=False,
JSON_SORT_KEYS=False, # Preserve order in JSON responses
MAX_CONTENT_LENGTH=50 * 1024 * 1024, # 50MB max upload size (supports config files up to ~2MB)
)
# Override with custom config if provided
if config:
app.config.update(config)
# Ensure instance folder exists
try:
os.makedirs(app.instance_path, exist_ok=True)
except OSError:
pass
# Configure logging
configure_logging(app)
# Initialize database
init_database(app)
# Initialize extensions
init_extensions(app)
# Initialize authentication
init_authentication(app)
# Initialize background scheduler
init_scheduler(app)
# Register blueprints
register_blueprints(app)
# Register error handlers
register_error_handlers(app)
# Add request/response handlers
register_request_handlers(app)
app.logger.info("SneakyScanner Flask app initialized")
return app
def configure_logging(app: Flask) -> None:
"""
Configure application logging with rotation and structured format.
Args:
app: Flask application instance
"""
# Set log level from environment or default to INFO
log_level = os.environ.get('LOG_LEVEL', 'INFO').upper()
app.logger.setLevel(getattr(logging, log_level, logging.INFO))
# Create logs directory if it doesn't exist
log_dir = Path('logs')
log_dir.mkdir(exist_ok=True)
# Rotating file handler for application logs
# Max 10MB per file, keep 10 backup files (100MB total)
app_log_handler = RotatingFileHandler(
log_dir / 'sneakyscanner.log',
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=10
)
app_log_handler.setLevel(logging.INFO)
# Structured log format with more context
log_formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] '
'%(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
app_log_handler.setFormatter(log_formatter)
# Add filter to inject request ID into log records
app_log_handler.addFilter(RequestIDLogFilter())
app.logger.addHandler(app_log_handler)
# Separate rotating file handler for errors only
error_log_handler = RotatingFileHandler(
log_dir / 'sneakyscanner_errors.log',
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5
)
error_log_handler.setLevel(logging.ERROR)
error_formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s]\n'
'Message: %(message)s\n'
'Path: %(pathname)s:%(lineno)d\n'
'%(stack_info)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
error_log_handler.setFormatter(error_formatter)
error_log_handler.addFilter(RequestIDLogFilter())
app.logger.addHandler(error_log_handler)
# Console handler for development
if app.debug:
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s [%(request_id)s] %(message)s',
datefmt='%H:%M:%S'
)
console_handler.setFormatter(console_formatter)
console_handler.addFilter(RequestIDLogFilter())
app.logger.addHandler(console_handler)
app.logger.info("Logging configured with rotation (10MB per file, 10 backups)")
def init_database(app: Flask) -> None:
"""
Initialize database connection and session management.
Args:
app: Flask application instance
"""
# Determine connect_args based on database type
connect_args = {}
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
# SQLite-specific configuration for better concurrency
connect_args = {
'timeout': 15, # 15 second timeout for database locks
'check_same_thread': False # Allow SQLite usage across threads
}
# Create engine
engine = create_engine(
app.config['SQLALCHEMY_DATABASE_URI'],
echo=app.debug, # Log SQL in debug mode
pool_pre_ping=True, # Verify connections before using
pool_recycle=3600, # Recycle connections after 1 hour
connect_args=connect_args
)
# Enable WAL mode for SQLite (better concurrency)
if 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI']:
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
"""Set SQLite pragmas for better performance and concurrency."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging
cursor.execute("PRAGMA synchronous=NORMAL") # Faster writes
cursor.execute("PRAGMA busy_timeout=15000") # 15 second busy timeout
cursor.close()
# Create scoped session factory
db_session = scoped_session(
sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)
)
# Store session in app for use in views
app.db_session = db_session
# Create tables if they don't exist (for development)
# In production, use Alembic migrations instead
if app.debug:
Base.metadata.create_all(bind=engine)
@app.teardown_appcontext
def shutdown_session(exception=None):
"""
Remove database session at end of request.
Rollback on exception to prevent partial commits.
"""
if exception:
app.logger.warning(f"Request ended with exception, rolling back database session")
db_session.rollback()
db_session.remove()
app.logger.info(f"Database initialized: {app.config['SQLALCHEMY_DATABASE_URI']}")
def init_extensions(app: Flask) -> None:
"""
Initialize Flask extensions.
Args:
app: Flask application instance
"""
# CORS support for API
CORS(app, resources={
r"/api/*": {
"origins": os.environ.get('CORS_ORIGINS', '*').split(','),
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"allow_headers": ["Content-Type", "Authorization"],
}
})
app.logger.info("Extensions initialized")
def init_authentication(app: Flask) -> None:
"""
Initialize Flask-Login authentication.
Args:
app: Flask application instance
"""
from web.auth.models import User
# Initialize LoginManager
login_manager = LoginManager()
login_manager.init_app(app)
# Configure login view
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
# User loader callback
@login_manager.user_loader
def load_user(user_id):
"""Load user by ID for Flask-Login."""
return User.get(user_id, app.db_session)
app.logger.info("Authentication initialized")
def init_scheduler(app: Flask) -> None:
"""
Initialize background job scheduler.
Args:
app: Flask application instance
"""
from web.services.scheduler_service import SchedulerService
from web.services.scan_service import ScanService
# Create and initialize scheduler
scheduler = SchedulerService()
scheduler.init_scheduler(app)
# Perform startup tasks with app context for database access
with app.app_context():
# Clean up any orphaned scans from previous crashes/restarts
scan_service = ScanService(app.db_session)
orphaned_count = scan_service.cleanup_orphaned_scans()
if orphaned_count > 0:
app.logger.warning(f"Cleaned up {orphaned_count} orphaned scan(s) on startup")
# Load all enabled schedules from database
scheduler.load_schedules_on_startup()
# Store in app context for access from routes
app.scheduler = scheduler
app.logger.info("Background scheduler initialized")
def register_blueprints(app: Flask) -> None:
"""
Register Flask blueprints for different app sections.
Args:
app: Flask application instance
"""
# Import blueprints
from web.api.scans import bp as scans_bp
from web.api.schedules import bp as schedules_bp
from web.api.alerts import bp as alerts_bp
from web.api.settings import bp as settings_bp
from web.api.stats import bp as stats_bp
from web.api.configs import bp as configs_bp
from web.auth.routes import bp as auth_bp
from web.routes.main import bp as main_bp
# Register authentication blueprint
app.register_blueprint(auth_bp, url_prefix='/auth')
# Register main web routes blueprint
app.register_blueprint(main_bp, url_prefix='/')
# Register API blueprints
app.register_blueprint(scans_bp, url_prefix='/api/scans')
app.register_blueprint(schedules_bp, url_prefix='/api/schedules')
app.register_blueprint(alerts_bp, url_prefix='/api/alerts')
app.register_blueprint(settings_bp, url_prefix='/api/settings')
app.register_blueprint(stats_bp, url_prefix='/api/stats')
app.register_blueprint(configs_bp, url_prefix='/api/configs')
app.logger.info("Blueprints registered")
def register_error_handlers(app: Flask) -> None:
"""
Register error handlers for common HTTP errors.
Handles errors with either JSON responses (for API requests) or
HTML templates (for web requests). Ensures database rollback on errors.
Args:
app: Flask application instance
"""
from flask import render_template
from sqlalchemy.exc import SQLAlchemyError
def wants_json():
"""Check if client wants JSON response."""
# API requests always get JSON
if request.path.startswith('/api/'):
return True
# Check Accept header
best = request.accept_mimetypes.best_match(['application/json', 'text/html'])
return best == 'application/json' and \
request.accept_mimetypes[best] > request.accept_mimetypes['text/html']
@app.errorhandler(400)
def bad_request(error):
"""Handle 400 Bad Request errors."""
app.logger.warning(f"Bad request: {request.path} - {str(error)}")
if wants_json():
return jsonify({
'error': 'Bad Request',
'message': str(error) or 'The request was invalid'
}), 400
return render_template('errors/400.html', error=error), 400
@app.errorhandler(401)
def unauthorized(error):
"""Handle 401 Unauthorized errors."""
app.logger.warning(f"Unauthorized access attempt: {request.path}")
if wants_json():
return jsonify({
'error': 'Unauthorized',
'message': 'Authentication required'
}), 401
return render_template('errors/401.html', error=error), 401
@app.errorhandler(403)
def forbidden(error):
"""Handle 403 Forbidden errors."""
app.logger.warning(f"Forbidden access: {request.path}")
if wants_json():
return jsonify({
'error': 'Forbidden',
'message': 'You do not have permission to access this resource'
}), 403
return render_template('errors/403.html', error=error), 403
@app.errorhandler(404)
def not_found(error):
"""Handle 404 Not Found errors."""
app.logger.info(f"Resource not found: {request.path}")
if wants_json():
return jsonify({
'error': 'Not Found',
'message': 'The requested resource was not found'
}), 404
return render_template('errors/404.html', error=error), 404
@app.errorhandler(405)
def method_not_allowed(error):
"""Handle 405 Method Not Allowed errors."""
app.logger.warning(f"Method not allowed: {request.method} {request.path}")
if wants_json():
return jsonify({
'error': 'Method Not Allowed',
'message': 'The HTTP method is not allowed for this endpoint'
}), 405
return render_template('errors/405.html', error=error), 405
@app.errorhandler(500)
def internal_server_error(error):
"""
Handle 500 Internal Server Error.
Rolls back database session and logs full traceback.
"""
# Rollback database session on error
try:
app.db_session.rollback()
except Exception as e:
app.logger.error(f"Failed to rollback database session: {str(e)}")
# Log error with full context
app.logger.error(
f"Internal server error: {request.method} {request.path} - {str(error)}",
exc_info=True
)
if wants_json():
return jsonify({
'error': 'Internal Server Error',
'message': 'An unexpected error occurred'
}), 500
return render_template('errors/500.html', error=error), 500
@app.errorhandler(SQLAlchemyError)
def handle_db_error(error):
"""
Handle database errors.
Rolls back transaction and returns appropriate error response.
"""
# Rollback database session
try:
app.db_session.rollback()
except Exception as e:
app.logger.error(f"Failed to rollback database session: {str(e)}")
# Log database error
app.logger.error(
f"Database error: {request.method} {request.path} - {str(error)}",
exc_info=True
)
if wants_json():
return jsonify({
'error': 'Database Error',
'message': 'A database error occurred'
}), 500
return render_template('errors/500.html', error=error), 500
def register_request_handlers(app: Flask) -> None:
"""
Register request and response handlers.
Adds request ID generation, request/response logging with timing,
and security headers.
Args:
app: Flask application instance
"""
import time
@app.before_request
def before_request_handler():
"""
Generate request ID and start timing.
Sets g.request_id and g.request_start_time for use in logging
and timing calculations.
"""
# Generate unique request ID
g.request_id = str(uuid.uuid4())[:8] # Short ID for readability
g.request_start_time = time.time()
# Log incoming request with context
user_info = 'anonymous'
if current_user.is_authenticated:
user_info = f'user:{current_user.get_id()}'
# Log at INFO level for API calls, DEBUG for other requests
if request.path.startswith('/api/'):
app.logger.info(
f"{request.method} {request.path} "
f"from={request.remote_addr} user={user_info}"
)
elif app.debug:
app.logger.debug(
f"{request.method} {request.path} "
f"from={request.remote_addr}"
)
@app.after_request
def after_request_handler(response):
"""
Log response and add security headers.
Calculates request duration and logs response status.
"""
# Calculate request duration
if hasattr(g, 'request_start_time'):
duration_ms = (time.time() - g.request_start_time) * 1000
# Log response with duration
if request.path.startswith('/api/'):
# Log API responses at INFO level
app.logger.info(
f"{request.method} {request.path} "
f"status={response.status_code} "
f"duration={duration_ms:.2f}ms"
)
elif app.debug:
# Log web responses at DEBUG level in debug mode
app.logger.debug(
f"{request.method} {request.path} "
f"status={response.status_code} "
f"duration={duration_ms:.2f}ms"
)
# Add duration header for API responses
if request.path.startswith('/api/'):
response.headers['X-Request-Duration-Ms'] = f"{duration_ms:.2f}"
response.headers['X-Request-ID'] = g.request_id
# Add security headers to all responses
if request.path.startswith('/api/'):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
return response
@app.teardown_request
def teardown_request_handler(exception=None):
"""
Log errors that occur during request processing.
Args:
exception: Exception that occurred, if any
"""
if exception:
app.logger.error(
f"Request failed: {request.method} {request.path} "
f"error={type(exception).__name__}: {str(exception)}",
exc_info=True
)
# Development server entry point
def main():
"""Run development server."""
app = create_app()
app.run(
host=os.environ.get('FLASK_HOST', '0.0.0.0'),
port=int(os.environ.get('FLASK_PORT', 5000)),
debug=os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
)
if __name__ == '__main__':
main()

9
app/web/auth/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
"""
Authentication package for SneakyScanner.
Provides Flask-Login based authentication with single-user support.
"""
from web.auth.models import User
__all__ = ['User']

View File

@@ -0,0 +1,65 @@
"""
Authentication decorators for SneakyScanner.
Provides decorators for protecting web routes and API endpoints.
"""
from functools import wraps
from typing import Callable
from flask import jsonify, redirect, request, url_for
from flask_login import current_user
def login_required(f: Callable) -> Callable:
"""
Decorator for web routes that require authentication.
Redirects to login page if user is not authenticated.
This is a wrapper around Flask-Login's login_required that can be
customized if needed.
Args:
f: Function to decorate
Returns:
Decorated function
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
# Redirect to login page
return redirect(url_for('auth.login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def api_auth_required(f: Callable) -> Callable:
"""
Decorator for API endpoints that require authentication.
Returns 401 JSON response if user is not authenticated.
Uses Flask-Login sessions (same as web UI).
Args:
f: Function to decorate
Returns:
Decorated function
Example:
@bp.route('/api/scans', methods=['POST'])
@api_auth_required
def trigger_scan():
# Protected endpoint
pass
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
return jsonify({
'error': 'Authentication required',
'message': 'Please authenticate to access this endpoint'
}), 401
return f(*args, **kwargs)
return decorated_function

107
app/web/auth/models.py Normal file
View File

@@ -0,0 +1,107 @@
"""
User model for Flask-Login authentication.
Simple single-user model that loads credentials from the settings table.
"""
from typing import Optional
from flask_login import UserMixin
from sqlalchemy.orm import Session
from web.utils.settings import PasswordManager, SettingsManager
class User(UserMixin):
"""
User class for Flask-Login.
Represents the single application user. Credentials are stored in the
settings table (app_password key).
"""
# Single user ID (always 1 for single-user app)
USER_ID = '1'
def __init__(self, user_id: str = USER_ID):
"""
Initialize user.
Args:
user_id: User ID (always '1' for single-user app)
"""
self.id = user_id
def get_id(self) -> str:
"""
Get user ID for Flask-Login.
Returns:
User ID string
"""
return self.id
@property
def is_authenticated(self) -> bool:
"""User is always authenticated if instance exists."""
return True
@property
def is_active(self) -> bool:
"""User is always active."""
return True
@property
def is_anonymous(self) -> bool:
"""User is never anonymous."""
return False
@staticmethod
def get(user_id: str, db_session: Session = None) -> Optional['User']:
"""
Get user by ID (Flask-Login user_loader).
Args:
user_id: User ID to load
db_session: Database session (unused - kept for compatibility)
Returns:
User instance if ID is valid, None otherwise
"""
if user_id == User.USER_ID:
return User(user_id)
return None
@staticmethod
def authenticate(password: str, db_session: Session) -> Optional['User']:
"""
Authenticate user with password.
Args:
password: Password to verify
db_session: Database session for accessing settings
Returns:
User instance if password is correct, None otherwise
"""
settings_manager = SettingsManager(db_session)
if PasswordManager.verify_app_password(settings_manager, password):
return User(User.USER_ID)
return None
@staticmethod
def has_password_set(db_session: Session) -> bool:
"""
Check if application password is set.
Args:
db_session: Database session for accessing settings
Returns:
True if password is set, False otherwise
"""
settings_manager = SettingsManager(db_session)
stored_hash = settings_manager.get('app_password', decrypt=False)
return bool(stored_hash)

120
app/web/auth/routes.py Normal file
View File

@@ -0,0 +1,120 @@
"""
Authentication routes for SneakyScanner.
Provides login and logout endpoints for user authentication.
"""
import logging
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from flask_login import login_user, logout_user, current_user
from web.auth.models import User
logger = logging.getLogger(__name__)
bp = Blueprint('auth', __name__)
@bp.route('/login', methods=['GET', 'POST'])
def login():
"""
Login page and authentication endpoint.
GET: Render login form
POST: Authenticate user and create session
Returns:
GET: Rendered login template
POST: Redirect to dashboard on success, login page with error on failure
"""
# If already logged in, redirect to dashboard
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
# Check if password is set
if not User.has_password_set(current_app.db_session):
flash('Application password not set. Please contact administrator.', 'error')
logger.warning("Login attempted but no password is set")
return render_template('login.html', password_not_set=True)
if request.method == 'POST':
password = request.form.get('password', '')
# Authenticate user
user = User.authenticate(password, current_app.db_session)
if user:
# Login successful
login_user(user, remember=request.form.get('remember', False))
logger.info(f"User logged in successfully from {request.remote_addr}")
# Redirect to next page or dashboard
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
return redirect(url_for('main.dashboard'))
else:
# Login failed
flash('Invalid password', 'error')
logger.warning(f"Failed login attempt from {request.remote_addr}")
return render_template('login.html')
@bp.route('/logout')
def logout():
"""
Logout endpoint.
Destroys the user session and redirects to login page.
Returns:
Redirect to login page
"""
if current_user.is_authenticated:
logger.info(f"User logged out from {request.remote_addr}")
logout_user()
flash('You have been logged out successfully', 'info')
return redirect(url_for('auth.login'))
@bp.route('/setup', methods=['GET', 'POST'])
def setup():
"""
Initial password setup page.
Only accessible when no password is set. Allows setting the application password.
Returns:
GET: Rendered setup template
POST: Redirect to login page on success
"""
# If password already set, redirect to login
if User.has_password_set(current_app.db_session):
flash('Password already set. Please login.', 'info')
return redirect(url_for('auth.login'))
if request.method == 'POST':
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
# Validate passwords
if not password:
flash('Password is required', 'error')
elif len(password) < 8:
flash('Password must be at least 8 characters', 'error')
elif password != confirm_password:
flash('Passwords do not match', 'error')
else:
# Set password
from web.utils.settings import PasswordManager, SettingsManager
settings_manager = SettingsManager(current_app.db_session)
PasswordManager.set_app_password(settings_manager, password)
logger.info(f"Application password set from {request.remote_addr}")
flash('Password set successfully! You can now login.', 'success')
return redirect(url_for('auth.login'))
return render_template('setup.html')

6
app/web/jobs/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
Background jobs package for SneakyScanner.
This package contains job definitions for background task execution,
including scan jobs and scheduled tasks.
"""

158
app/web/jobs/scan_job.py Normal file
View File

@@ -0,0 +1,158 @@
"""
Background scan job execution.
This module handles the execution of scans in background threads,
updating database status and handling errors.
"""
import logging
import traceback
from datetime import datetime
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.scanner import SneakyScanner
from web.models import Scan
from web.services.scan_service import ScanService
logger = logging.getLogger(__name__)
def execute_scan(scan_id: int, config_file: str, db_url: str):
"""
Execute a scan 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:
scan_id: ID of the scan record in database
config_file: Path to YAML configuration file
db_url: Database connection URL
Workflow:
1. Create new database session for this thread
2. Update scan status to 'running'
3. Execute scanner
4. Generate output files (JSON, HTML, ZIP)
5. Save results to database
6. Update status to 'completed' or 'failed'
"""
logger.info(f"Starting background scan execution: scan_id={scan_id}, config={config_file}")
# Create new database session for this thread
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Get scan record
scan = session.query(Scan).filter_by(id=scan_id).first()
if not scan:
logger.error(f"Scan {scan_id} not found in database")
return
# Update status to running (in case it wasn't already)
scan.status = 'running'
scan.started_at = datetime.utcnow()
session.commit()
logger.info(f"Scan {scan_id}: Initializing scanner with config {config_file}")
# Convert config_file to full path if it's just a filename
if not config_file.startswith('/'):
config_path = f'/app/configs/{config_file}'
else:
config_path = config_file
# Initialize scanner
scanner = SneakyScanner(config_path)
# Execute scan
logger.info(f"Scan {scan_id}: Running scanner...")
start_time = datetime.utcnow()
report, timestamp = scanner.scan()
end_time = datetime.utcnow()
scan_duration = (end_time - start_time).total_seconds()
logger.info(f"Scan {scan_id}: Scanner completed in {scan_duration:.2f} seconds")
# Generate output files (JSON, HTML, ZIP)
logger.info(f"Scan {scan_id}: Generating output files...")
scanner.generate_outputs(report, timestamp)
# Save results to database
logger.info(f"Scan {scan_id}: Saving results to database...")
scan_service = ScanService(session)
scan_service._save_scan_to_db(report, scan_id, status='completed')
logger.info(f"Scan {scan_id}: Completed successfully")
except FileNotFoundError as e:
# Config file not found
error_msg = f"Configuration file not found: {str(e)}"
logger.error(f"Scan {scan_id}: {error_msg}")
scan = session.query(Scan).filter_by(id=scan_id).first()
if scan:
scan.status = 'failed'
scan.error_message = error_msg
scan.completed_at = datetime.utcnow()
session.commit()
except Exception as e:
# Any other error during scan execution
error_msg = f"Scan execution failed: {str(e)}"
logger.error(f"Scan {scan_id}: {error_msg}")
logger.error(f"Scan {scan_id}: Traceback:\n{traceback.format_exc()}")
try:
scan = session.query(Scan).filter_by(id=scan_id).first()
if scan:
scan.status = 'failed'
scan.error_message = error_msg
scan.completed_at = datetime.utcnow()
session.commit()
except Exception as db_error:
logger.error(f"Scan {scan_id}: Failed to update error status in database: {str(db_error)}")
finally:
# Always close the session
session.close()
logger.info(f"Scan {scan_id}: Background job completed, session closed")
def get_scan_status_from_db(scan_id: int, db_url: str) -> dict:
"""
Helper function to get scan status directly from database.
Useful for monitoring background jobs without needing Flask app context.
Args:
scan_id: Scan ID to check
db_url: Database connection URL
Returns:
Dictionary with scan status information
"""
engine = create_engine(db_url, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
try:
scan = session.query(Scan).filter_by(id=scan_id).first()
if not scan:
return None
return {
'scan_id': scan.id,
'status': scan.status,
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
'duration': scan.duration,
'error_message': scan.error_message
}
finally:
session.close()

348
app/web/models.py Normal file
View File

@@ -0,0 +1,348 @@
"""
SQLAlchemy models for SneakyScanner database.
This module defines all database tables for storing scan results, schedules,
alerts, and application settings. The schema supports the full scanning workflow
from port discovery through service detection and SSL/TLS analysis.
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
"""Base class for all SQLAlchemy models."""
pass
# ============================================================================
# Core Scan Tables
# ============================================================================
class Scan(Base):
"""
Stores metadata about each scan execution.
This is the parent table that ties together all scan results including
sites, IPs, ports, services, certificates, and TLS configuration.
"""
__tablename__ = 'scans'
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
status = Column(String(20), nullable=False, default='running', comment="running, completed, failed")
config_file = Column(Text, nullable=True, comment="Path to YAML config used")
title = Column(Text, nullable=True, comment="Scan title from config")
json_path = Column(Text, nullable=True, comment="Path to JSON report")
html_path = Column(Text, nullable=True, comment="Path to HTML report")
zip_path = Column(Text, nullable=True, comment="Path to ZIP archive")
screenshot_dir = Column(Text, nullable=True, comment="Path to screenshot directory")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Record creation time")
triggered_by = Column(String(50), nullable=False, default='manual', comment="manual, scheduled, api")
schedule_id = Column(Integer, ForeignKey('schedules.id'), nullable=True, comment="FK to schedules if triggered by schedule")
started_at = Column(DateTime, nullable=True, comment="Scan execution start time")
completed_at = Column(DateTime, nullable=True, comment="Scan execution completion time")
error_message = Column(Text, nullable=True, comment="Error message if scan failed")
# Relationships
sites = relationship('ScanSite', back_populates='scan', cascade='all, delete-orphan')
ips = relationship('ScanIP', back_populates='scan', cascade='all, delete-orphan')
ports = relationship('ScanPort', back_populates='scan', cascade='all, delete-orphan')
services = relationship('ScanService', back_populates='scan', cascade='all, delete-orphan')
certificates = relationship('ScanCertificate', back_populates='scan', cascade='all, delete-orphan')
tls_versions = relationship('ScanTLSVersion', back_populates='scan', cascade='all, delete-orphan')
alerts = relationship('Alert', back_populates='scan', cascade='all, delete-orphan')
schedule = relationship('Schedule', back_populates='scans')
def __repr__(self):
return f"<Scan(id={self.id}, title='{self.title}', status='{self.status}')>"
class ScanSite(Base):
"""
Logical grouping of IPs by site.
Sites represent logical network segments or locations (e.g., "Production DC",
"DMZ", "Branch Office") as defined in the scan configuration.
"""
__tablename__ = 'scan_sites'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
site_name = Column(String(255), nullable=False, comment="Site name from config")
# Relationships
scan = relationship('Scan', back_populates='sites')
ips = relationship('ScanIP', back_populates='site', cascade='all, delete-orphan')
def __repr__(self):
return f"<ScanSite(id={self.id}, site_name='{self.site_name}')>"
class ScanIP(Base):
"""
IP addresses scanned in each scan.
Stores the target IPs and their ping response status (expected vs. actual).
"""
__tablename__ = 'scan_ips'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
site_id = Column(Integer, ForeignKey('scan_sites.id'), nullable=False, index=True)
ip_address = Column(String(45), nullable=False, comment="IPv4 or IPv6 address")
ping_expected = Column(Boolean, nullable=True, comment="Expected ping response")
ping_actual = Column(Boolean, nullable=True, comment="Actual ping response")
# Relationships
scan = relationship('Scan', back_populates='ips')
site = relationship('ScanSite', back_populates='ips')
ports = relationship('ScanPort', back_populates='ip', cascade='all, delete-orphan')
# Index for efficient IP lookups within a scan
__table_args__ = (
UniqueConstraint('scan_id', 'ip_address', name='uix_scan_ip'),
)
def __repr__(self):
return f"<ScanIP(id={self.id}, ip_address='{self.ip_address}')>"
class ScanPort(Base):
"""
Discovered TCP/UDP ports.
Stores all open ports found during masscan phase, along with expected vs.
actual status for drift detection.
"""
__tablename__ = 'scan_ports'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
ip_id = Column(Integer, ForeignKey('scan_ips.id'), nullable=False, index=True)
port = Column(Integer, nullable=False, comment="Port number (1-65535)")
protocol = Column(String(10), nullable=False, comment="tcp or udp")
expected = Column(Boolean, nullable=True, comment="Was this port expected?")
state = Column(String(20), nullable=False, default='open', comment="open, closed, filtered")
# Relationships
scan = relationship('Scan', back_populates='ports')
ip = relationship('ScanIP', back_populates='ports')
services = relationship('ScanService', back_populates='port', cascade='all, delete-orphan')
# Index for efficient port lookups
__table_args__ = (
UniqueConstraint('scan_id', 'ip_id', 'port', 'protocol', name='uix_scan_ip_port'),
)
def __repr__(self):
return f"<ScanPort(id={self.id}, port={self.port}, protocol='{self.protocol}')>"
class ScanService(Base):
"""
Detected services on open ports.
Stores nmap service detection results including product names, versions,
and HTTP/HTTPS information with screenshots.
"""
__tablename__ = 'scan_services'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
port_id = Column(Integer, ForeignKey('scan_ports.id'), nullable=False, index=True)
service_name = Column(String(100), nullable=True, comment="Service name (e.g., ssh, http)")
product = Column(String(255), nullable=True, comment="Product name (e.g., OpenSSH)")
version = Column(String(100), nullable=True, comment="Version string")
extrainfo = Column(Text, nullable=True, comment="Additional nmap info")
ostype = Column(String(100), nullable=True, comment="OS type if detected")
http_protocol = Column(String(10), nullable=True, comment="http or https (if web service)")
screenshot_path = Column(Text, nullable=True, comment="Relative path to screenshot")
# Relationships
scan = relationship('Scan', back_populates='services')
port = relationship('ScanPort', back_populates='services')
certificates = relationship('ScanCertificate', back_populates='service', cascade='all, delete-orphan')
def __repr__(self):
return f"<ScanService(id={self.id}, service_name='{self.service_name}', product='{self.product}')>"
class ScanCertificate(Base):
"""
SSL/TLS certificates discovered on HTTPS services.
Stores certificate details including validity periods, subject/issuer,
and flags for self-signed certificates.
"""
__tablename__ = 'scan_certificates'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
service_id = Column(Integer, ForeignKey('scan_services.id'), nullable=False, index=True)
subject = Column(Text, nullable=True, comment="Certificate subject (CN)")
issuer = Column(Text, nullable=True, comment="Certificate issuer")
serial_number = Column(Text, nullable=True, comment="Serial number")
not_valid_before = Column(DateTime, nullable=True, comment="Validity start date")
not_valid_after = Column(DateTime, nullable=True, comment="Validity end date")
days_until_expiry = Column(Integer, nullable=True, comment="Days until expiration")
sans = Column(Text, nullable=True, comment="JSON array of SANs")
is_self_signed = Column(Boolean, nullable=True, default=False, comment="Self-signed certificate flag")
# Relationships
scan = relationship('Scan', back_populates='certificates')
service = relationship('ScanService', back_populates='certificates')
tls_versions = relationship('ScanTLSVersion', back_populates='certificate', cascade='all, delete-orphan')
# Index for certificate expiration queries
__table_args__ = (
{'comment': 'Index on expiration date for alert queries'},
)
def __repr__(self):
return f"<ScanCertificate(id={self.id}, subject='{self.subject}', days_until_expiry={self.days_until_expiry})>"
class ScanTLSVersion(Base):
"""
TLS version support and cipher suites.
Stores which TLS versions (1.0, 1.1, 1.2, 1.3) are supported by each
HTTPS service, along with accepted cipher suites.
"""
__tablename__ = 'scan_tls_versions'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
certificate_id = Column(Integer, ForeignKey('scan_certificates.id'), nullable=False, index=True)
tls_version = Column(String(20), nullable=False, comment="TLS 1.0, TLS 1.1, TLS 1.2, TLS 1.3")
supported = Column(Boolean, nullable=False, comment="Is this version supported?")
cipher_suites = Column(Text, nullable=True, comment="JSON array of cipher suites")
# Relationships
scan = relationship('Scan', back_populates='tls_versions')
certificate = relationship('ScanCertificate', back_populates='tls_versions')
def __repr__(self):
return f"<ScanTLSVersion(id={self.id}, tls_version='{self.tls_version}', supported={self.supported})>"
# ============================================================================
# Scheduling & Notifications Tables
# ============================================================================
class Schedule(Base):
"""
Scheduled scan configurations.
Stores cron-like schedules for automated periodic scanning of network
infrastructure.
"""
__tablename__ = 'schedules'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
config_file = Column(Text, nullable=False, comment="Path to YAML config")
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")
last_run = Column(DateTime, nullable=True, comment="Last execution time")
next_run = Column(DateTime, nullable=True, comment="Next scheduled execution")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Schedule creation time")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
# Relationships
scans = relationship('Scan', back_populates='schedule')
def __repr__(self):
return f"<Schedule(id={self.id}, name='{self.name}', enabled={self.enabled})>"
class Alert(Base):
"""
Alert history and notifications sent.
Stores all alerts generated by the alert rule engine, including severity
levels and email notification status.
"""
__tablename__ = 'alerts'
id = Column(Integer, primary_key=True, autoincrement=True)
scan_id = Column(Integer, ForeignKey('scans.id'), nullable=False, index=True)
alert_type = Column(String(50), nullable=False, comment="new_port, cert_expiry, service_change, ping_failed")
severity = Column(String(20), nullable=False, comment="info, warning, critical")
message = Column(Text, nullable=False, comment="Human-readable alert message")
ip_address = Column(String(45), nullable=True, comment="Related IP (optional)")
port = Column(Integer, nullable=True, comment="Related port (optional)")
email_sent = Column(Boolean, nullable=False, default=False, comment="Was email notification sent?")
email_sent_at = Column(DateTime, nullable=True, comment="Email send timestamp")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Alert creation time")
# Relationships
scan = relationship('Scan', back_populates='alerts')
# Index for alert queries by type and severity
__table_args__ = (
{'comment': 'Indexes for alert filtering'},
)
def __repr__(self):
return f"<Alert(id={self.id}, alert_type='{self.alert_type}', severity='{self.severity}')>"
class AlertRule(Base):
"""
User-defined alert rules.
Configurable rules that trigger alerts based on scan results (e.g.,
certificates expiring in <30 days, unexpected ports opened).
"""
__tablename__ = 'alert_rules'
id = Column(Integer, primary_key=True, autoincrement=True)
rule_type = Column(String(50), nullable=False, comment="unexpected_port, cert_expiry, service_down, etc.")
enabled = Column(Boolean, nullable=False, default=True, comment="Is rule active?")
threshold = Column(Integer, nullable=True, comment="Threshold value (e.g., days for cert expiry)")
email_enabled = Column(Boolean, nullable=False, default=False, comment="Send email for this rule?")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow, comment="Rule creation time")
def __repr__(self):
return f"<AlertRule(id={self.id}, rule_type='{self.rule_type}', enabled={self.enabled})>"
# ============================================================================
# Settings Table
# ============================================================================
class Setting(Base):
"""
Application configuration key-value store.
Stores application settings including SMTP configuration, authentication,
and retention policies. Values stored as JSON for complex data types.
"""
__tablename__ = 'settings'
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(255), nullable=False, unique=True, index=True, comment="Setting key (e.g., smtp_server)")
value = Column(Text, nullable=True, comment="Setting value (JSON for complex values)")
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, comment="Last modification time")
def __repr__(self):
return f"<Setting(key='{self.key}', value='{self.value[:50] if self.value else None}...')>"

View File

@@ -0,0 +1,5 @@
"""
Main web routes package for SneakyScanner.
Provides web UI routes (dashboard, scan views, etc.).
"""

221
app/web/routes/main.py Normal file
View File

@@ -0,0 +1,221 @@
"""
Main web routes for SneakyScanner.
Provides dashboard and scan viewing pages.
"""
import logging
from flask import Blueprint, current_app, redirect, render_template, url_for
from web.auth.decorators import login_required
logger = logging.getLogger(__name__)
bp = Blueprint('main', __name__)
@bp.route('/')
def index():
"""
Root route - redirect to dashboard.
Returns:
Redirect to dashboard
"""
return redirect(url_for('main.dashboard'))
@bp.route('/dashboard')
@login_required
def dashboard():
"""
Dashboard page - shows recent scans and statistics.
Returns:
Rendered dashboard template
"""
import os
# 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('dashboard.html', config_files=config_files)
@bp.route('/scans')
@login_required
def scans():
"""
Scans list page - shows all scans with pagination.
Returns:
Rendered scans list template
"""
import os
# 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('scans.html', config_files=config_files)
@bp.route('/scans/<int:scan_id>')
@login_required
def scan_detail(scan_id):
"""
Scan detail page - shows full scan results.
Args:
scan_id: Scan ID to display
Returns:
Rendered scan detail template
"""
# TODO: Phase 5 - Implement scan detail page
return render_template('scan_detail.html', scan_id=scan_id)
@bp.route('/scans/<int:scan_id1>/compare/<int:scan_id2>')
@login_required
def compare_scans(scan_id1, scan_id2):
"""
Scan comparison page - shows differences between two scans.
Args:
scan_id1: First (older) scan ID
scan_id2: Second (newer) scan ID
Returns:
Rendered comparison template
"""
return render_template('scan_compare.html', scan_id1=scan_id1, scan_id2=scan_id2)
@bp.route('/schedules')
@login_required
def schedules():
"""
Schedules list page - shows all scheduled scans.
Returns:
Rendered schedules list template
"""
return render_template('schedules.html')
@bp.route('/schedules/create')
@login_required
def create_schedule():
"""
Create new schedule form page.
Returns:
Rendered schedule create template with available config files
"""
import os
# 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')]
config_files.sort()
except Exception as e:
logger.error(f"Error listing config files: {e}")
return render_template('schedule_create.html', config_files=config_files)
@bp.route('/schedules/<int:schedule_id>/edit')
@login_required
def edit_schedule(schedule_id):
"""
Edit existing schedule form page.
Args:
schedule_id: Schedule ID to edit
Returns:
Rendered schedule edit template
"""
from flask import flash
# Note: Schedule data is loaded via AJAX in the template
# This just renders the page with the schedule_id in the URL
return render_template('schedule_edit.html', schedule_id=schedule_id)
@bp.route('/configs')
@login_required
def configs():
"""
Configuration files list page - shows all config files.
Returns:
Rendered configs list template
"""
return render_template('configs.html')
@bp.route('/configs/upload')
@login_required
def upload_config():
"""
Config upload page - allows CIDR/YAML upload.
Returns:
Rendered config upload template
"""
return render_template('config_upload.html')
@bp.route('/configs/edit/<filename>')
@login_required
def edit_config(filename):
"""
Config edit page - allows editing YAML configuration.
Args:
filename: Config filename to edit
Returns:
Rendered config edit template
"""
from web.services.config_service import ConfigService
from flask import flash, redirect
try:
config_service = ConfigService()
config_data = config_service.get_config(filename)
return render_template(
'config_edit.html',
filename=config_data['filename'],
content=config_data['content']
)
except FileNotFoundError:
flash(f"Config file '{filename}' not found", 'error')
return redirect(url_for('main.configs'))
except Exception as e:
logger.error(f"Error loading config for edit: {e}")
flash(f"Error loading config: {str(e)}", 'error')
return redirect(url_for('main.configs'))

View File

@@ -0,0 +1,10 @@
"""
Services package for SneakyScanner web application.
This package contains business logic layer services that orchestrate
operations between API endpoints and database models.
"""
from web.services.scan_service import ScanService
__all__ = ['ScanService']

View File

@@ -0,0 +1,552 @@
"""
Config Service - Business logic for config file management
This service handles all operations related to scan configuration files,
including creation, validation, listing, and deletion.
"""
import os
import re
import yaml
import ipaddress
from typing import Dict, List, Tuple, Any, Optional
from datetime import datetime
from pathlib import Path
from werkzeug.utils import secure_filename
class ConfigService:
"""Business logic for config management"""
def __init__(self, configs_dir: str = '/app/configs'):
"""
Initialize the config service.
Args:
configs_dir: Directory where config files are stored
"""
self.configs_dir = configs_dir
# Ensure configs directory exists
os.makedirs(self.configs_dir, exist_ok=True)
def list_configs(self) -> List[Dict[str, Any]]:
"""
List all config files with metadata.
Returns:
List of config metadata dictionaries:
[
{
"filename": "prod-scan.yaml",
"title": "Prod Scan",
"path": "/app/configs/prod-scan.yaml",
"created_at": "2025-11-15T10:30:00Z",
"size_bytes": 1234,
"used_by_schedules": ["Daily Scan", "Weekly Audit"]
}
]
"""
configs = []
# Get all YAML files in configs directory
if not os.path.exists(self.configs_dir):
return configs
for filename in os.listdir(self.configs_dir):
if not filename.endswith(('.yaml', '.yml')):
continue
filepath = os.path.join(self.configs_dir, filename)
if not os.path.isfile(filepath):
continue
try:
# Get file metadata
stat_info = os.stat(filepath)
created_at = datetime.fromtimestamp(stat_info.st_mtime).isoformat() + 'Z'
size_bytes = stat_info.st_size
# Parse YAML to get title
title = None
try:
with open(filepath, 'r') as f:
data = yaml.safe_load(f)
if isinstance(data, dict):
title = data.get('title', filename)
except Exception:
title = filename # Fallback to filename if parsing fails
# Get schedules using this config
used_by_schedules = self.get_schedules_using_config(filename)
configs.append({
'filename': filename,
'title': title,
'path': filepath,
'created_at': created_at,
'size_bytes': size_bytes,
'used_by_schedules': used_by_schedules
})
except Exception as e:
# Skip files that can't be read
continue
# Sort by created_at (most recent first)
configs.sort(key=lambda x: x['created_at'], reverse=True)
return configs
def get_config(self, filename: str) -> Dict[str, Any]:
"""
Get config file content and parsed data.
Args:
filename: Config filename
Returns:
{
"filename": "prod-scan.yaml",
"content": "title: Prod Scan\n...",
"parsed": {"title": "Prod Scan", "sites": [...]}
}
Raises:
FileNotFoundError: If config doesn't exist
ValueError: If config content is invalid
"""
filepath = os.path.join(self.configs_dir, filename)
if not os.path.exists(filepath):
raise FileNotFoundError(f"Config file '{filename}' not found")
# Read file content
with open(filepath, 'r') as f:
content = f.read()
# Parse YAML
try:
parsed = yaml.safe_load(content)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML syntax: {str(e)}")
return {
'filename': filename,
'content': content,
'parsed': parsed
}
def create_from_yaml(self, filename: str, content: str) -> str:
"""
Create config from YAML content.
Args:
filename: Desired filename (will be sanitized)
content: YAML content string
Returns:
Final filename (sanitized)
Raises:
ValueError: If content invalid or filename conflict
"""
# Sanitize filename
filename = secure_filename(filename)
# Ensure .yaml extension
if not filename.endswith(('.yaml', '.yml')):
filename += '.yaml'
filepath = os.path.join(self.configs_dir, filename)
# Check for conflicts
if os.path.exists(filepath):
raise ValueError(f"Config file '{filename}' already exists")
# Parse and validate YAML
try:
parsed = yaml.safe_load(content)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML syntax: {str(e)}")
# Validate config structure
is_valid, error_msg = self.validate_config_content(parsed)
if not is_valid:
raise ValueError(f"Invalid config structure: {error_msg}")
# Write file
with open(filepath, 'w') as f:
f.write(content)
return filename
def create_from_cidr(
self,
title: str,
cidr: str,
site_name: Optional[str] = None,
ping_default: bool = False
) -> Tuple[str, str]:
"""
Create config from CIDR range.
Args:
title: Scan configuration title
cidr: CIDR range (e.g., "10.0.0.0/24")
site_name: Optional site name (defaults to "Site 1")
ping_default: Default ping expectation for all IPs
Returns:
Tuple of (final_filename, yaml_content)
Raises:
ValueError: If CIDR invalid or other validation errors
"""
# Validate and parse CIDR
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
raise ValueError(f"Invalid CIDR range: {str(e)}")
# Check if network is too large (prevent expansion of huge ranges)
if network.num_addresses > 10000:
raise ValueError(f"CIDR range too large: {network.num_addresses} addresses. Maximum is 10,000.")
# Expand CIDR to list of IP addresses
ip_list = [str(ip) for ip in network.hosts()]
# If network has only 1 address (like /32 or /128), hosts() returns empty
# In that case, use the network address itself
if not ip_list:
ip_list = [str(network.network_address)]
# Build site name
if not site_name or not site_name.strip():
site_name = "Site 1"
# Build IP configurations
ips = []
for ip_address in ip_list:
ips.append({
'address': ip_address,
'expected': {
'ping': ping_default,
'tcp_ports': [],
'udp_ports': []
}
})
# Build YAML structure
config_data = {
'title': title.strip(),
'sites': [
{
'name': site_name.strip(),
'ips': ips
}
]
}
# Convert to YAML string
yaml_content = yaml.dump(config_data, sort_keys=False, default_flow_style=False)
# Generate filename from title
filename = self.generate_filename_from_title(title)
filepath = os.path.join(self.configs_dir, filename)
# Check for conflicts
if os.path.exists(filepath):
raise ValueError(f"Config file '{filename}' already exists")
# Write file
with open(filepath, 'w') as f:
f.write(yaml_content)
return filename, yaml_content
def update_config(self, filename: str, yaml_content: str) -> None:
"""
Update existing config file with new YAML content.
Args:
filename: Config filename to update
yaml_content: New YAML content string
Raises:
FileNotFoundError: If config doesn't exist
ValueError: If YAML content is invalid
"""
filepath = os.path.join(self.configs_dir, filename)
# Check if file exists
if not os.path.exists(filepath):
raise FileNotFoundError(f"Config file '{filename}' not found")
# Parse and validate YAML
try:
parsed = yaml.safe_load(yaml_content)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML syntax: {str(e)}")
# Validate config structure
is_valid, error_msg = self.validate_config_content(parsed)
if not is_valid:
raise ValueError(f"Invalid config structure: {error_msg}")
# Write updated content
with open(filepath, 'w') as f:
f.write(yaml_content)
def delete_config(self, filename: str) -> None:
"""
Delete config file and cascade delete any associated schedules.
When a config is deleted, all schedules using that config (both enabled
and disabled) are automatically deleted as well, since they would be
invalid without the config file.
Args:
filename: Config filename to delete
Raises:
FileNotFoundError: If config doesn't exist
"""
filepath = os.path.join(self.configs_dir, filename)
if not os.path.exists(filepath):
raise FileNotFoundError(f"Config file '{filename}' not found")
# Delete any schedules using this config (both enabled and disabled)
try:
from web.services.schedule_service import ScheduleService
from flask import current_app
# Get database session from Flask app
db = current_app.db_session
# Get all schedules
schedule_service = ScheduleService(db)
result = schedule_service.list_schedules(page=1, per_page=10000)
schedules = result.get('schedules', [])
# Build full path for comparison
config_path = os.path.join(self.configs_dir, filename)
# Find and delete all schedules using this config (enabled or disabled)
deleted_schedules = []
for schedule in schedules:
schedule_config = schedule.get('config_file', '')
# Handle both absolute paths and just filenames
if schedule_config == filename or schedule_config == config_path:
schedule_id = schedule.get('id')
schedule_name = schedule.get('name', 'Unknown')
try:
schedule_service.delete_schedule(schedule_id)
deleted_schedules.append(schedule_name)
except Exception as e:
import logging
logging.getLogger(__name__).warning(
f"Failed to delete schedule {schedule_id} ('{schedule_name}'): {e}"
)
if deleted_schedules:
import logging
logging.getLogger(__name__).info(
f"Cascade deleted {len(deleted_schedules)} schedule(s) associated with config '{filename}': {', '.join(deleted_schedules)}"
)
except ImportError:
# If ScheduleService doesn't exist yet, skip schedule deletion
pass
except Exception as e:
# Log error but continue with config deletion
import logging
logging.getLogger(__name__).error(
f"Error deleting schedules for config {filename}: {e}", exc_info=True
)
# Delete file
os.remove(filepath)
def validate_config_content(self, content: Dict) -> Tuple[bool, str]:
"""
Validate parsed YAML config structure.
Args:
content: Parsed YAML config as dict
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(content, dict):
return False, "Config must be a dictionary/object"
# Check required fields
if 'title' not in content:
return False, "Missing required field: 'title'"
if 'sites' not in content:
return False, "Missing required field: 'sites'"
# Validate title
if not isinstance(content['title'], str) or not content['title'].strip():
return False, "Field 'title' must be a non-empty string"
# Validate sites
sites = content['sites']
if not isinstance(sites, list):
return False, "Field 'sites' must be a list"
if len(sites) == 0:
return False, "Must have at least one site defined"
# Validate each site
for i, site in enumerate(sites):
if not isinstance(site, dict):
return False, f"Site {i+1} must be a dictionary/object"
if 'name' not in site:
return False, f"Site {i+1} missing required field: 'name'"
if 'ips' not in site:
return False, f"Site {i+1} missing required field: 'ips'"
if not isinstance(site['ips'], list):
return False, f"Site {i+1} field 'ips' must be a list"
if len(site['ips']) == 0:
return False, f"Site {i+1} must have at least one IP"
# Validate each IP
for j, ip_config in enumerate(site['ips']):
if not isinstance(ip_config, dict):
return False, f"Site {i+1} IP {j+1} must be a dictionary/object"
if 'address' not in ip_config:
return False, f"Site {i+1} IP {j+1} missing required field: 'address'"
if 'expected' not in ip_config:
return False, f"Site {i+1} IP {j+1} missing required field: 'expected'"
if not isinstance(ip_config['expected'], dict):
return False, f"Site {i+1} IP {j+1} field 'expected' must be a dictionary/object"
return True, ""
def get_schedules_using_config(self, filename: str) -> List[str]:
"""
Get list of schedule names using this config.
Args:
filename: Config filename
Returns:
List of schedule names (e.g., ["Daily Scan", "Weekly Audit"])
"""
# Import here to avoid circular dependency
try:
from web.services.schedule_service import ScheduleService
from flask import current_app
# Get database session from Flask app
db = current_app.db_session
# Get all schedules (use large per_page to get all)
schedule_service = ScheduleService(db)
result = schedule_service.list_schedules(page=1, per_page=10000)
# Extract schedules list from paginated result
schedules = result.get('schedules', [])
# Build full path for comparison
config_path = os.path.join(self.configs_dir, filename)
# Find schedules using this config (only enabled schedules)
using_schedules = []
for schedule in schedules:
schedule_config = schedule.get('config_file', '')
# Handle both absolute paths and just filenames
if schedule_config == filename or schedule_config == config_path:
# Only count enabled schedules
if schedule.get('enabled', False):
using_schedules.append(schedule.get('name', 'Unknown'))
return using_schedules
except ImportError:
# If ScheduleService doesn't exist yet, return empty list
return []
except Exception as e:
# If any error occurs, return empty list (safer than failing)
# Log the error for debugging
import logging
logging.getLogger(__name__).error(f"Error getting schedules using config {filename}: {e}", exc_info=True)
return []
def generate_filename_from_title(self, title: str) -> str:
"""
Generate safe filename from scan title.
Args:
title: Scan title string
Returns:
Safe filename (e.g., "Prod Scan 2025" -> "prod-scan-2025.yaml")
"""
# Convert to lowercase
filename = title.lower()
# Replace spaces with hyphens
filename = filename.replace(' ', '-')
# Remove special characters (keep only alphanumeric, hyphens, underscores)
filename = re.sub(r'[^a-z0-9\-_]', '', filename)
# Remove consecutive hyphens
filename = re.sub(r'-+', '-', filename)
# Remove leading/trailing hyphens
filename = filename.strip('-')
# Limit length (max 200 chars, reserve 5 for .yaml)
max_length = 195
if len(filename) > max_length:
filename = filename[:max_length]
# Ensure not empty
if not filename:
filename = 'config'
# Add .yaml extension
filename += '.yaml'
return filename
def get_config_path(self, filename: str) -> str:
"""
Get absolute path for a config file.
Args:
filename: Config filename
Returns:
Absolute path to config file
"""
return os.path.join(self.configs_dir, filename)
def config_exists(self, filename: str) -> bool:
"""
Check if a config file exists.
Args:
filename: Config filename
Returns:
True if file exists, False otherwise
"""
filepath = os.path.join(self.configs_dir, filename)
return os.path.exists(filepath) and os.path.isfile(filepath)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
"""
Schedule service for managing scheduled scan operations.
This service handles the business logic for creating, updating, and managing
scheduled scans with cron expressions.
"""
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from croniter import croniter
from sqlalchemy.orm import Session
from web.models import Schedule, Scan
from web.utils.pagination import paginate, PaginatedResult
logger = logging.getLogger(__name__)
class ScheduleService:
"""
Service for managing scheduled scans.
Handles schedule lifecycle: creation, validation, updating,
and cron expression processing.
"""
def __init__(self, db_session: Session):
"""
Initialize schedule service.
Args:
db_session: SQLAlchemy database session
"""
self.db = db_session
def create_schedule(
self,
name: str,
config_file: str,
cron_expression: str,
enabled: bool = True
) -> int:
"""
Create a new schedule.
Args:
name: Human-readable schedule name
config_file: Path to YAML configuration file
cron_expression: Cron expression (e.g., '0 2 * * *')
enabled: Whether schedule is active
Returns:
Schedule ID of the created schedule
Raises:
ValueError: If cron expression is invalid or config file doesn't exist
"""
# Validate cron expression
is_valid, error_msg = self.validate_cron_expression(cron_expression)
if not is_valid:
raise ValueError(f"Invalid cron expression: {error_msg}")
# Validate config file exists
# If config_file is just a filename, prepend the configs directory
if not config_file.startswith('/'):
config_file_path = os.path.join('/app/configs', config_file)
else:
config_file_path = config_file
if not os.path.isfile(config_file_path):
raise ValueError(f"Config file not found: {config_file}")
# Calculate next run time
next_run = self.calculate_next_run(cron_expression) if enabled else None
# Create schedule record
schedule = Schedule(
name=name,
config_file=config_file,
cron_expression=cron_expression,
enabled=enabled,
last_run=None,
next_run=next_run,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
self.db.add(schedule)
self.db.commit()
self.db.refresh(schedule)
logger.info(f"Schedule {schedule.id} created: '{name}' with cron '{cron_expression}'")
return schedule.id
def get_schedule(self, schedule_id: int) -> Dict[str, Any]:
"""
Get schedule details by ID.
Args:
schedule_id: Schedule ID
Returns:
Schedule dictionary with details and execution history
Raises:
ValueError: If schedule not found
"""
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
if not schedule:
raise ValueError(f"Schedule {schedule_id} not found")
# Convert to dict and include history
schedule_dict = self._schedule_to_dict(schedule)
schedule_dict['history'] = self.get_schedule_history(schedule_id, limit=10)
return schedule_dict
def list_schedules(
self,
page: int = 1,
per_page: int = 20,
enabled_filter: Optional[bool] = None
) -> Dict[str, Any]:
"""
List all schedules with pagination and filtering.
Args:
page: Page number (1-indexed)
per_page: Items per page
enabled_filter: Filter by enabled status (None = all)
Returns:
Dictionary with paginated schedules:
{
'schedules': [...],
'total': int,
'page': int,
'per_page': int,
'pages': int
}
"""
# Build query
query = self.db.query(Schedule)
# Apply filter
if enabled_filter is not None:
query = query.filter(Schedule.enabled == enabled_filter)
# Order by next_run (nulls last), then by name
query = query.order_by(Schedule.next_run.is_(None), Schedule.next_run, Schedule.name)
# Paginate
result = paginate(query, page=page, per_page=per_page)
# Convert schedules to dicts
schedules = [self._schedule_to_dict(s) for s in result.items]
return {
'schedules': schedules,
'total': result.total,
'page': result.page,
'per_page': result.per_page,
'pages': result.pages
}
def update_schedule(
self,
schedule_id: int,
**updates: Any
) -> Dict[str, Any]:
"""
Update schedule fields.
Args:
schedule_id: Schedule ID
**updates: Fields to update (name, config_file, cron_expression, enabled)
Returns:
Updated schedule dictionary
Raises:
ValueError: If schedule not found or invalid updates
"""
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
if not schedule:
raise ValueError(f"Schedule {schedule_id} not found")
# Validate cron expression if being updated
if 'cron_expression' in updates:
is_valid, error_msg = self.validate_cron_expression(updates['cron_expression'])
if not is_valid:
raise ValueError(f"Invalid cron expression: {error_msg}")
# Recalculate next_run
if schedule.enabled or updates.get('enabled', False):
updates['next_run'] = self.calculate_next_run(updates['cron_expression'])
# Validate config file if being updated
if 'config_file' in updates:
config_file = updates['config_file']
# If config_file is just a filename, prepend the configs directory
if not config_file.startswith('/'):
config_file_path = os.path.join('/app/configs', config_file)
else:
config_file_path = config_file
if not os.path.isfile(config_file_path):
raise ValueError(f"Config file not found: {updates['config_file']}")
# Handle enabled toggle
if 'enabled' in updates:
if updates['enabled'] and not schedule.enabled:
# Being enabled - calculate next_run
cron_expr = updates.get('cron_expression', schedule.cron_expression)
updates['next_run'] = self.calculate_next_run(cron_expr)
elif not updates['enabled'] and schedule.enabled:
# Being disabled - clear next_run
updates['next_run'] = None
# Update fields
for key, value in updates.items():
if hasattr(schedule, key):
setattr(schedule, key, value)
schedule.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(schedule)
logger.info(f"Schedule {schedule_id} updated: {list(updates.keys())}")
return self._schedule_to_dict(schedule)
def delete_schedule(self, schedule_id: int) -> bool:
"""
Delete a schedule.
Note: Associated scans are NOT deleted (schedule_id becomes null).
Args:
schedule_id: Schedule ID
Returns:
True if deleted successfully
Raises:
ValueError: If schedule not found
"""
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
if not schedule:
raise ValueError(f"Schedule {schedule_id} not found")
schedule_name = schedule.name
self.db.delete(schedule)
self.db.commit()
logger.info(f"Schedule {schedule_id} ('{schedule_name}') deleted")
return True
def toggle_enabled(self, schedule_id: int, enabled: bool) -> Dict[str, Any]:
"""
Enable or disable a schedule.
Args:
schedule_id: Schedule ID
enabled: New enabled status
Returns:
Updated schedule dictionary
Raises:
ValueError: If schedule not found
"""
return self.update_schedule(schedule_id, enabled=enabled)
def update_run_times(
self,
schedule_id: int,
last_run: datetime,
next_run: datetime
) -> bool:
"""
Update last_run and next_run timestamps.
Called after each execution.
Args:
schedule_id: Schedule ID
last_run: Last execution time
next_run: Next scheduled execution time
Returns:
True if updated successfully
Raises:
ValueError: If schedule not found
"""
schedule = self.db.query(Schedule).filter(Schedule.id == schedule_id).first()
if not schedule:
raise ValueError(f"Schedule {schedule_id} not found")
schedule.last_run = last_run
schedule.next_run = next_run
schedule.updated_at = datetime.utcnow()
self.db.commit()
logger.debug(f"Schedule {schedule_id} run times updated: last={last_run}, next={next_run}")
return True
def validate_cron_expression(self, cron_expr: str) -> Tuple[bool, Optional[str]]:
"""
Validate a cron expression.
Args:
cron_expr: Cron expression to validate
Returns:
Tuple of (is_valid, error_message)
- (True, None) if valid
- (False, error_message) if invalid
"""
try:
# Try to create a croniter instance
base_time = datetime.utcnow()
cron = croniter(cron_expr, base_time)
# Try to get the next run time (validates the expression)
cron.get_next(datetime)
return (True, None)
except (ValueError, KeyError) as e:
return (False, str(e))
except Exception as e:
return (False, f"Unexpected error: {str(e)}")
def calculate_next_run(
self,
cron_expr: str,
from_time: Optional[datetime] = None
) -> datetime:
"""
Calculate next run time from cron expression.
Args:
cron_expr: Cron expression
from_time: Base time (defaults to now UTC)
Returns:
Next run datetime (UTC)
Raises:
ValueError: If cron expression is invalid
"""
if from_time is None:
from_time = datetime.utcnow()
try:
cron = croniter(cron_expr, from_time)
return cron.get_next(datetime)
except Exception as e:
raise ValueError(f"Invalid cron expression '{cron_expr}': {str(e)}")
def get_schedule_history(
self,
schedule_id: int,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
Get recent scans triggered by this schedule.
Args:
schedule_id: Schedule ID
limit: Maximum number of scans to return
Returns:
List of scan dictionaries (recent first)
"""
scans = (
self.db.query(Scan)
.filter(Scan.schedule_id == schedule_id)
.order_by(Scan.timestamp.desc())
.limit(limit)
.all()
)
return [
{
'id': scan.id,
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
'status': scan.status,
'title': scan.title,
'config_file': scan.config_file
}
for scan in scans
]
def _schedule_to_dict(self, schedule: Schedule) -> Dict[str, Any]:
"""
Convert Schedule model to dictionary.
Args:
schedule: Schedule model instance
Returns:
Dictionary representation
"""
return {
'id': schedule.id,
'name': schedule.name,
'config_file': schedule.config_file,
'cron_expression': schedule.cron_expression,
'enabled': schedule.enabled,
'last_run': schedule.last_run.isoformat() if schedule.last_run else None,
'next_run': schedule.next_run.isoformat() if schedule.next_run else None,
'next_run_relative': self._get_relative_time(schedule.next_run) if schedule.next_run else None,
'created_at': schedule.created_at.isoformat() if schedule.created_at else None,
'updated_at': schedule.updated_at.isoformat() if schedule.updated_at else None
}
def _get_relative_time(self, dt: Optional[datetime]) -> Optional[str]:
"""
Format datetime as relative time.
Args:
dt: Datetime to format (UTC)
Returns:
Human-readable relative time (e.g., "in 2 hours", "yesterday")
"""
if dt is None:
return None
now = datetime.utcnow()
diff = dt - now
# Future times
if diff.total_seconds() > 0:
seconds = int(diff.total_seconds())
if seconds < 60:
return "in less than a minute"
elif seconds < 3600:
minutes = seconds // 60
return f"in {minutes} minute{'s' if minutes != 1 else ''}"
elif seconds < 86400:
hours = seconds // 3600
return f"in {hours} hour{'s' if hours != 1 else ''}"
elif seconds < 604800:
days = seconds // 86400
return f"in {days} day{'s' if days != 1 else ''}"
else:
weeks = seconds // 604800
return f"in {weeks} week{'s' if weeks != 1 else ''}"
# Past times
else:
seconds = int(-diff.total_seconds())
if seconds < 60:
return "less than a minute ago"
elif seconds < 3600:
minutes = seconds // 60
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
elif seconds < 86400:
hours = seconds // 3600
return f"{hours} hour{'s' if hours != 1 else ''} ago"
elif seconds < 604800:
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''} ago"
else:
weeks = seconds // 604800
return f"{weeks} week{'s' if weeks != 1 else ''} ago"

View File

@@ -0,0 +1,356 @@
"""
Scheduler service for managing background jobs and scheduled scans.
This service integrates APScheduler with Flask to enable background
scan execution and future scheduled scanning capabilities.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.executors.pool import ThreadPoolExecutor
from flask import Flask
from web.jobs.scan_job import execute_scan
logger = logging.getLogger(__name__)
class SchedulerService:
"""
Service for managing background job scheduling.
Uses APScheduler's BackgroundScheduler to run scans asynchronously
without blocking HTTP requests.
"""
def __init__(self):
"""Initialize scheduler service (scheduler not started yet)."""
self.scheduler: Optional[BackgroundScheduler] = None
self.db_url: Optional[str] = None
def init_scheduler(self, app: Flask):
"""
Initialize and start APScheduler with Flask app.
Args:
app: Flask application instance
Configuration:
- BackgroundScheduler: Runs in separate thread
- ThreadPoolExecutor: Allows concurrent scan execution
- Max workers: 3 (configurable via SCHEDULER_MAX_WORKERS)
"""
if self.scheduler:
logger.warning("Scheduler already initialized")
return
# Store database URL for passing to background jobs
self.db_url = app.config['SQLALCHEMY_DATABASE_URI']
# Configure executor for concurrent jobs
max_workers = app.config.get('SCHEDULER_MAX_WORKERS', 3)
executors = {
'default': ThreadPoolExecutor(max_workers=max_workers)
}
# Configure job defaults
job_defaults = {
'coalesce': True, # Combine multiple pending instances into one
'max_instances': app.config.get('SCHEDULER_MAX_INSTANCES', 3),
'misfire_grace_time': 60 # Allow 60 seconds for delayed starts
}
# Create scheduler with local system timezone
# This allows users to schedule jobs using their local time
# APScheduler will automatically use the system's local timezone
self.scheduler = BackgroundScheduler(
executors=executors,
job_defaults=job_defaults
# timezone defaults to local system timezone
)
# Start scheduler
self.scheduler.start()
logger.info(f"APScheduler started with {max_workers} max workers")
# Register shutdown handler
import atexit
atexit.register(lambda: self.shutdown())
def shutdown(self):
"""
Shutdown scheduler gracefully.
Waits for running jobs to complete before shutting down.
"""
if self.scheduler:
logger.info("Shutting down APScheduler...")
self.scheduler.shutdown(wait=True)
logger.info("APScheduler shutdown complete")
self.scheduler = None
def load_schedules_on_startup(self):
"""
Load all enabled schedules from database and register with APScheduler.
Should be called after init_scheduler() to restore scheduled jobs
that were active when the application last shutdown.
Raises:
RuntimeError: If scheduler not initialized
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
# Import here to avoid circular imports
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.models import Schedule
try:
# Create database session
engine = create_engine(self.db_url)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Query all enabled schedules
enabled_schedules = (
session.query(Schedule)
.filter(Schedule.enabled == True)
.all()
)
logger.info(f"Loading {len(enabled_schedules)} enabled schedules on startup")
# Register each schedule with APScheduler
for schedule in enabled_schedules:
try:
self.add_scheduled_scan(
schedule_id=schedule.id,
config_file=schedule.config_file,
cron_expression=schedule.cron_expression
)
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
except Exception as e:
logger.error(
f"Failed to load schedule {schedule.id} ('{schedule.name}'): {str(e)}",
exc_info=True
)
logger.info("Schedule loading complete")
finally:
session.close()
except Exception as e:
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
def queue_scan(self, scan_id: int, config_file: str) -> str:
"""
Queue a scan for immediate background execution.
Args:
scan_id: Database ID of the scan
config_file: Path to YAML configuration file
Returns:
Job ID from APScheduler
Raises:
RuntimeError: If scheduler not initialized
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
# Add job to run immediately
job = self.scheduler.add_job(
func=execute_scan,
args=[scan_id, config_file, self.db_url],
id=f'scan_{scan_id}',
name=f'Scan {scan_id}',
replace_existing=True,
misfire_grace_time=300 # 5 minutes
)
logger.info(f"Queued scan {scan_id} for background execution (job_id={job.id})")
return job.id
def add_scheduled_scan(self, schedule_id: int, config_file: str,
cron_expression: str) -> str:
"""
Add a recurring scheduled scan.
Args:
schedule_id: Database ID of the schedule
config_file: Path to YAML configuration file
cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily)
Returns:
Job ID from APScheduler
Raises:
RuntimeError: If scheduler not initialized
ValueError: If cron expression is invalid
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
from apscheduler.triggers.cron import CronTrigger
# Create cron trigger from expression using local timezone
# This allows users to specify times in their local timezone
try:
trigger = CronTrigger.from_crontab(cron_expression)
# timezone defaults to local system timezone
except (ValueError, KeyError) as e:
raise ValueError(f"Invalid cron expression '{cron_expression}': {str(e)}")
# Add cron job
job = self.scheduler.add_job(
func=self._trigger_scheduled_scan,
args=[schedule_id],
trigger=trigger,
id=f'schedule_{schedule_id}',
name=f'Schedule {schedule_id}',
replace_existing=True,
max_instances=1 # Only one instance per schedule
)
logger.info(f"Added scheduled scan {schedule_id} with cron '{cron_expression}' (job_id={job.id})")
return job.id
def remove_scheduled_scan(self, schedule_id: int):
"""
Remove a scheduled scan job.
Args:
schedule_id: Database ID of the schedule
Raises:
RuntimeError: If scheduler not initialized
"""
if not self.scheduler:
raise RuntimeError("Scheduler not initialized. Call init_scheduler() first.")
job_id = f'schedule_{schedule_id}'
try:
self.scheduler.remove_job(job_id)
logger.info(f"Removed scheduled scan job: {job_id}")
except Exception as e:
logger.warning(f"Failed to remove scheduled scan job {job_id}: {str(e)}")
def _trigger_scheduled_scan(self, schedule_id: int):
"""
Internal method to trigger a scan from a schedule.
Creates a new scan record and queues it for execution.
Args:
schedule_id: Database ID of the schedule
"""
logger.info(f"Scheduled scan triggered: schedule_id={schedule_id}")
# Import here to avoid circular imports
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.services.schedule_service import ScheduleService
from web.services.scan_service import ScanService
try:
# Create database session
engine = create_engine(self.db_url)
Session = sessionmaker(bind=engine)
session = Session()
try:
# Get schedule details
schedule_service = ScheduleService(session)
schedule = schedule_service.get_schedule(schedule_id)
if not schedule:
logger.error(f"Schedule {schedule_id} not found")
return
if not schedule['enabled']:
logger.warning(f"Schedule {schedule_id} is disabled, skipping execution")
return
# Create and trigger scan
scan_service = ScanService(session)
scan_id = scan_service.trigger_scan(
config_file=schedule['config_file'],
triggered_by='scheduled',
schedule_id=schedule_id,
scheduler=None # Don't pass scheduler to avoid recursion
)
# Queue the scan for execution
self.queue_scan(scan_id, schedule['config_file'])
# Update schedule's last_run and next_run
from croniter import croniter
next_run = croniter(schedule['cron_expression'], datetime.utcnow()).get_next(datetime)
schedule_service.update_run_times(
schedule_id=schedule_id,
last_run=datetime.utcnow(),
next_run=next_run
)
logger.info(f"Scheduled scan completed: schedule_id={schedule_id}, scan_id={scan_id}")
finally:
session.close()
except Exception as e:
logger.error(f"Error triggering scheduled scan {schedule_id}: {str(e)}", exc_info=True)
def get_job_status(self, job_id: str) -> Optional[dict]:
"""
Get status of a scheduled job.
Args:
job_id: APScheduler job ID
Returns:
Dictionary with job information, or None if not found
"""
if not self.scheduler:
return None
job = self.scheduler.get_job(job_id)
if not job:
return None
return {
'id': job.id,
'name': job.name,
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
'trigger': str(job.trigger)
}
def list_jobs(self) -> list:
"""
List all scheduled jobs.
Returns:
List of job information dictionaries
"""
if not self.scheduler:
return []
jobs = self.scheduler.get_jobs()
return [
{
'id': job.id,
'name': job.name,
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
'trigger': str(job.trigger)
}
for job in jobs
]

View File

@@ -0,0 +1,507 @@
/**
* Config Manager Styles
* Phase 4: Config Creator - CSS styling for config management UI
*/
/* ============================================
Dropzone Styling
============================================ */
.dropzone {
border: 2px dashed #6c757d;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #1e293b;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.dropzone:hover {
border-color: #0d6efd;
background-color: #2d3748;
}
.dropzone.dragover {
border-color: #0d6efd;
background-color: #1a365d;
border-width: 3px;
}
.dropzone i {
font-size: 48px;
color: #94a3b8;
margin-bottom: 16px;
display: block;
}
.dropzone p {
color: #cbd5e0;
margin: 0;
font-size: 1rem;
}
.dropzone:hover i {
color: #0d6efd;
}
/* ============================================
Preview Pane Styling
============================================ */
#yaml-preview {
background-color: #1e293b;
border-radius: 8px;
padding: 16px;
}
#yaml-preview pre {
background-color: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
padding: 16px;
max-height: 500px;
overflow-y: auto;
margin: 0;
}
#yaml-preview pre code {
color: #e2e8f0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
line-height: 1.6;
white-space: pre;
}
#preview-placeholder {
background-color: #1e293b;
border: 2px dashed #475569;
border-radius: 8px;
padding: 60px 20px;
text-align: center;
color: #94a3b8;
}
#preview-placeholder i {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
opacity: 0.5;
}
/* ============================================
Config Table Styling
============================================ */
#configs-table {
background-color: #1e293b;
border-radius: 8px;
overflow: hidden;
}
#configs-table thead {
background-color: #0f172a;
border-bottom: 2px solid #334155;
}
#configs-table thead th {
color: #cbd5e0;
font-weight: 600;
padding: 12px 16px;
border: none;
}
#configs-table tbody tr {
border-bottom: 1px solid #334155;
transition: background-color 0.2s ease;
}
#configs-table tbody tr:hover {
background-color: #2d3748;
}
#configs-table tbody td {
padding: 12px 16px;
color: #e2e8f0;
vertical-align: middle;
border: none;
}
#configs-table tbody td code {
background-color: #0f172a;
padding: 2px 6px;
border-radius: 4px;
color: #60a5fa;
font-size: 0.9rem;
}
/* ============================================
Action Buttons
============================================ */
.config-actions {
white-space: nowrap;
}
.config-actions .btn {
margin-right: 4px;
padding: 4px 8px;
font-size: 0.875rem;
}
.config-actions .btn:last-child {
margin-right: 0;
}
.config-actions .btn i {
font-size: 1rem;
}
/* Disabled button styling */
.config-actions .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ============================================
Schedule Badge
============================================ */
.schedule-badge {
display: inline-block;
background-color: #3b82f6;
color: white;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
min-width: 24px;
text-align: center;
cursor: help;
}
.schedule-badge:hover {
background-color: #2563eb;
}
/* ============================================
Search Box
============================================ */
#search {
background-color: #1e293b;
border: 1px solid #475569;
color: #e2e8f0;
padding: 8px 12px;
border-radius: 6px;
transition: border-color 0.2s ease;
}
#search:focus {
background-color: #0f172a;
border-color: #3b82f6;
color: #e2e8f0;
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
#search::placeholder {
color: #64748b;
}
/* ============================================
Alert Messages
============================================ */
.alert {
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
}
.alert-danger {
background-color: #7f1d1d;
border: 1px solid #991b1b;
color: #fecaca;
}
.alert-success {
background-color: #14532d;
border: 1px solid #166534;
color: #86efac;
}
.alert i {
margin-right: 8px;
}
/* ============================================
Card Styling
============================================ */
.card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
margin-bottom: 20px;
}
.card-body {
padding: 20px;
}
.card h5 {
color: #cbd5e0;
margin-bottom: 16px;
}
.card .text-muted {
color: #94a3b8 !important;
}
/* ============================================
Tab Navigation
============================================ */
.nav-tabs {
border-bottom: 2px solid #334155;
}
.nav-tabs .nav-link {
color: #94a3b8;
border: none;
border-bottom: 2px solid transparent;
padding: 12px 20px;
transition: all 0.2s ease;
}
.nav-tabs .nav-link:hover {
color: #cbd5e0;
background-color: #2d3748;
border-color: transparent;
}
.nav-tabs .nav-link.active {
color: #60a5fa;
background-color: transparent;
border-color: transparent transparent #60a5fa transparent;
}
/* ============================================
Buttons
============================================ */
.btn {
border-radius: 6px;
padding: 8px 16px;
font-weight: 500;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #3b82f6;
border-color: #3b82f6;
}
.btn-primary:hover {
background-color: #2563eb;
border-color: #2563eb;
}
.btn-success {
background-color: #22c55e;
border-color: #22c55e;
}
.btn-success:hover {
background-color: #16a34a;
border-color: #16a34a;
}
.btn-outline-secondary {
color: #94a3b8;
border-color: #475569;
}
.btn-outline-secondary:hover {
background-color: #475569;
border-color: #475569;
color: #e2e8f0;
}
.btn-outline-primary {
color: #60a5fa;
border-color: #3b82f6;
}
.btn-outline-primary:hover {
background-color: #3b82f6;
border-color: #3b82f6;
color: white;
}
.btn-outline-danger {
color: #f87171;
border-color: #dc2626;
}
.btn-outline-danger:hover {
background-color: #dc2626;
border-color: #dc2626;
color: white;
}
/* ============================================
Modal Styling
============================================ */
.modal-content {
background-color: #1e293b;
border: 1px solid #334155;
color: #e2e8f0;
}
.modal-header {
border-bottom: 1px solid #334155;
}
.modal-footer {
border-top: 1px solid #334155;
}
.modal-title {
color: #cbd5e0;
}
.btn-close {
filter: invert(1);
}
/* ============================================
Spinner/Loading
============================================ */
.spinner-border {
color: #3b82f6;
}
/* ============================================
Responsive Adjustments
============================================ */
@media (max-width: 768px) {
#configs-table {
font-size: 0.875rem;
}
#configs-table thead th,
#configs-table tbody td {
padding: 8px 12px;
}
.config-actions .btn {
padding: 2px 6px;
margin-right: 2px;
}
.config-actions .btn i {
font-size: 0.9rem;
}
.dropzone {
padding: 30px 15px;
min-height: 150px;
}
.dropzone i {
font-size: 36px;
}
#yaml-preview pre {
max-height: 300px;
font-size: 0.8rem;
}
}
@media (max-width: 576px) {
/* Stack table cells on very small screens */
#configs-table thead {
display: none;
}
#configs-table tbody tr {
display: block;
margin-bottom: 16px;
border: 1px solid #334155;
border-radius: 8px;
padding: 12px;
}
#configs-table tbody td {
display: block;
text-align: left;
padding: 6px 0;
border: none;
}
#configs-table tbody td:before {
content: attr(data-label);
font-weight: 600;
color: #94a3b8;
display: inline-block;
width: 100px;
}
.config-actions {
margin-top: 8px;
}
}
/* ============================================
Utility Classes
============================================ */
.text-center {
text-align: center;
}
.py-4 {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.py-5 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-3 {
margin-top: 1rem;
}
.mb-3 {
margin-bottom: 1rem;
}
.mb-4 {
margin-bottom: 1.5rem;
}
/* ============================================
Result Count Display
============================================ */
#result-count {
color: #94a3b8;
font-size: 0.9rem;
font-weight: 500;
}

View File

@@ -0,0 +1,334 @@
/* CSS Variables */
:root {
/* Custom Variables */
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-quaternary: #475569;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #334155;
--accent-blue: #60a5fa;
--success-bg: #065f46;
--success-text: #6ee7b7;
--success-border: #10b981;
--warning-bg: #78350f;
--warning-text: #fcd34d;
--warning-border: #f59e0b;
--danger-bg: #7f1d1d;
--danger-text: #fca5a5;
--danger-border: #ef4444;
--info-bg: #1e3a8a;
--info-text: #93c5fd;
--info-border: #3b82f6;
/* Bootstrap 5 Variable Overrides for Dark Theme */
--bs-body-bg: #0f172a;
--bs-body-color: #e2e8f0;
--bs-border-color: #334155;
--bs-border-color-translucent: rgba(51, 65, 85, 0.5);
/* Table Variables */
--bs-table-bg: #1e293b;
--bs-table-color: #e2e8f0;
--bs-table-border-color: #334155;
--bs-table-striped-bg: #1e293b;
--bs-table-striped-color: #e2e8f0;
--bs-table-active-bg: #334155;
--bs-table-active-color: #e2e8f0;
--bs-table-hover-bg: #334155;
--bs-table-hover-color: #e2e8f0;
}
/* Global Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
/* Navbar */
.navbar-custom {
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-bottom: 1px solid var(--bg-quaternary);
padding: 1rem 0;
}
.navbar-brand {
font-size: 1.5rem;
font-weight: 600;
color: var(--accent-blue) !important;
}
.nav-link {
color: var(--text-secondary) !important;
transition: color 0.2s;
}
.nav-link:hover,
.nav-link.active {
color: var(--accent-blue) !important;
}
/* Container */
.container-fluid {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Cards */
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 25px;
}
.card-header {
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--bg-quaternary);
padding: 15px 20px;
border-radius: 12px 12px 0 0 !important;
}
.card-body {
padding: 25px;
}
.card-title {
color: var(--accent-blue);
font-size: 1.5rem;
margin-bottom: 15px;
}
/* Badges */
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-expected,
.badge-good,
.badge-success {
background-color: var(--success-bg);
color: var(--success-text);
}
.badge-unexpected,
.badge-critical,
.badge-danger {
background-color: var(--danger-bg);
color: var(--danger-text);
}
.badge-missing,
.badge-warning {
background-color: var(--warning-bg);
color: var(--warning-text);
}
.badge-info {
background-color: var(--info-bg);
color: var(--info-text);
}
/* Buttons */
.btn-primary {
background-color: #3b82f6;
border-color: #3b82f6;
color: #ffffff;
}
.btn-primary:hover {
background-color: #2563eb;
border-color: #2563eb;
}
.btn-secondary {
background-color: var(--bg-tertiary);
border-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover {
background-color: var(--bg-quaternary);
border-color: var(--bg-quaternary);
}
.btn-danger {
background-color: var(--danger-bg);
border-color: var(--danger-bg);
color: var(--danger-text);
}
.btn-danger:hover {
background-color: #991b1b;
border-color: #991b1b;
}
/* Tables - Fix for dynamically created table rows (white row bug) */
.table {
color: var(--text-primary);
border-color: var(--border-color);
}
.table thead {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
}
.table tbody tr,
.table tbody tr.scan-row {
background-color: var(--bg-secondary) !important;
border-color: var(--border-color) !important;
}
.table tbody tr:hover {
background-color: var(--bg-tertiary) !important;
cursor: pointer;
}
.table th,
.table td {
padding: 12px;
border-color: var(--border-color);
}
/* Alerts */
.alert {
border-radius: 8px;
border: 1px solid;
}
.alert-success {
background-color: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
.alert-danger {
background-color: var(--danger-bg);
border-color: var(--danger-border);
color: var(--danger-text);
}
.alert-warning {
background-color: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
.alert-info {
background-color: var(--info-bg);
border-color: var(--info-border);
color: var(--info-text);
}
/* Form Controls */
.form-control,
.form-select {
background-color: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-primary);
}
.form-control:focus,
.form-select:focus {
background-color: var(--bg-secondary);
border-color: var(--accent-blue);
color: var(--text-primary);
box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25);
}
.form-label {
color: var(--text-secondary);
font-weight: 500;
}
/* Stats Cards */
.stat-card {
background-color: var(--bg-primary);
padding: 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--accent-blue);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 5px;
}
/* Footer */
.footer {
margin-top: 40px;
padding: 20px 0;
border-top: 1px solid var(--border-color);
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
}
/* Utilities */
.text-muted {
color: var(--text-secondary) !important;
}
.text-success {
color: var(--success-border) !important;
}
.text-warning {
color: var(--warning-border) !important;
}
.text-danger {
color: var(--danger-border) !important;
}
.text-info {
color: var(--accent-blue) !important;
}
.mono {
font-family: 'Courier New', monospace;
}
/* Spinner for loading states */
.spinner-border-sm {
color: var(--accent-blue);
}
/* Chart.js Dark Theme Styles */
.chart-container {
position: relative;
height: 300px;
margin: 20px 0;
}
canvas {
max-width: 100%;
height: auto;
}

View File

@@ -0,0 +1,633 @@
/**
* Config Manager - Handles configuration file upload, management, and display
* Phase 4: Config Creator
*/
class ConfigManager {
constructor() {
this.apiBase = '/api/configs';
this.currentPreview = null;
this.currentFilename = null;
}
/**
* Load all configurations and populate the table
*/
async loadConfigs() {
try {
const response = await fetch(this.apiBase);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
this.renderConfigsTable(data.configs || []);
return data.configs;
} catch (error) {
console.error('Error loading configs:', error);
this.showError('Failed to load configurations: ' + error.message);
return [];
}
}
/**
* Get a specific configuration file
*/
async getConfig(filename) {
try {
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error getting config:', error);
this.showError('Failed to load configuration: ' + error.message);
throw error;
}
}
/**
* Upload CSV file and convert to YAML
*/
async uploadCSV(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(`${this.apiBase}/upload-csv`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
}
return data;
} catch (error) {
console.error('Error uploading CSV:', error);
throw error;
}
}
/**
* Upload YAML file directly
*/
async uploadYAML(file, filename = null) {
const formData = new FormData();
formData.append('file', file);
if (filename) {
formData.append('filename', filename);
}
try {
const response = await fetch(`${this.apiBase}/upload-yaml`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
}
return data;
} catch (error) {
console.error('Error uploading YAML:', error);
throw error;
}
}
/**
* Delete a configuration file
*/
async deleteConfig(filename) {
try {
const response = await fetch(`${this.apiBase}/${encodeURIComponent(filename)}`, {
method: 'DELETE'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || `HTTP ${response.status}: ${response.statusText}`);
}
return data;
} catch (error) {
console.error('Error deleting config:', error);
throw error;
}
}
/**
* Download CSV template
*/
downloadTemplate() {
window.location.href = `${this.apiBase}/template`;
}
/**
* Download a specific config file
*/
downloadConfig(filename) {
window.location.href = `${this.apiBase}/${encodeURIComponent(filename)}/download`;
}
/**
* Show YAML preview in the preview pane
*/
showPreview(yamlContent, filename = null) {
this.currentPreview = yamlContent;
this.currentFilename = filename;
const previewElement = document.getElementById('yaml-preview');
const contentElement = document.getElementById('yaml-content');
const placeholderElement = document.getElementById('preview-placeholder');
if (contentElement) {
contentElement.textContent = yamlContent;
}
if (previewElement) {
previewElement.style.display = 'block';
}
if (placeholderElement) {
placeholderElement.style.display = 'none';
}
// Enable save button
const saveBtn = document.getElementById('save-config-btn');
if (saveBtn) {
saveBtn.disabled = false;
}
}
/**
* Hide YAML preview
*/
hidePreview() {
this.currentPreview = null;
this.currentFilename = null;
const previewElement = document.getElementById('yaml-preview');
const placeholderElement = document.getElementById('preview-placeholder');
if (previewElement) {
previewElement.style.display = 'none';
}
if (placeholderElement) {
placeholderElement.style.display = 'block';
}
// Disable save button
const saveBtn = document.getElementById('save-config-btn');
if (saveBtn) {
saveBtn.disabled = true;
}
}
/**
* Render configurations table
*/
renderConfigsTable(configs) {
const tbody = document.querySelector('#configs-table tbody');
if (!tbody) {
console.warn('Configs table body not found');
return;
}
// Clear existing rows
tbody.innerHTML = '';
if (configs.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="text-center text-muted py-4">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p class="mt-2">No configuration files found. Create your first config!</p>
</td>
</tr>
`;
return;
}
// Populate table
configs.forEach(config => {
const row = document.createElement('tr');
row.dataset.filename = config.filename;
// Format date
const createdDate = config.created_at ?
new Date(config.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) : 'Unknown';
// Format file size
const fileSize = config.size_bytes ?
this.formatFileSize(config.size_bytes) : 'Unknown';
// Schedule usage badge
const scheduleCount = config.used_by_schedules ? config.used_by_schedules.length : 0;
const scheduleBadge = scheduleCount > 0 ?
`<span class="schedule-badge" title="${config.used_by_schedules.join(', ')}">${scheduleCount}</span>` :
'<span class="text-muted">None</span>';
row.innerHTML = `
<td><code>${this.escapeHtml(config.filename)}</code></td>
<td>${this.escapeHtml(config.title || 'Untitled')}</td>
<td>${createdDate}</td>
<td>${fileSize}</td>
<td>${scheduleBadge}</td>
<td class="config-actions">
<button class="btn btn-sm btn-outline-secondary"
onclick="configManager.viewConfig('${this.escapeHtml(config.filename)}')"
title="View config">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-outline-primary"
onclick="configManager.downloadConfig('${this.escapeHtml(config.filename)}')"
title="Download config">
<i class="bi bi-download"></i>
</button>
<button class="btn btn-sm btn-outline-danger"
onclick="configManager.confirmDelete('${this.escapeHtml(config.filename)}', ${scheduleCount})"
title="Delete config"
${scheduleCount > 0 ? 'disabled' : ''}>
<i class="bi bi-trash"></i>
</button>
</td>
`;
tbody.appendChild(row);
});
// Update result count
this.updateResultCount(configs.length);
}
/**
* View/preview a configuration file
*/
async viewConfig(filename) {
try {
const config = await this.getConfig(filename);
// Show modal with config content
const modalHtml = `
<div class="modal fade" id="viewConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${this.escapeHtml(filename)}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre><code class="language-yaml">${this.escapeHtml(config.content)}</code></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary"
onclick="configManager.downloadConfig('${this.escapeHtml(filename)}')">
<i class="bi bi-download"></i> Download
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('viewConfigModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to page
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('viewConfigModal'));
modal.show();
// Clean up on close
document.getElementById('viewConfigModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
} catch (error) {
this.showError('Failed to view configuration: ' + error.message);
}
}
/**
* Confirm deletion of a configuration
*/
confirmDelete(filename, scheduleCount) {
if (scheduleCount > 0) {
this.showError(`Cannot delete "${filename}" - it is used by ${scheduleCount} schedule(s)`);
return;
}
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis action cannot be undone.`)) {
this.performDelete(filename);
}
}
/**
* Perform the actual deletion
*/
async performDelete(filename) {
try {
await this.deleteConfig(filename);
this.showSuccess(`Configuration "${filename}" deleted successfully`);
// Reload configs table
await this.loadConfigs();
} catch (error) {
this.showError('Failed to delete configuration: ' + error.message);
}
}
/**
* Filter configs table by search term
*/
filterConfigs(searchTerm) {
const term = searchTerm.toLowerCase().trim();
const rows = document.querySelectorAll('#configs-table tbody tr');
let visibleCount = 0;
rows.forEach(row => {
// Skip empty state row
if (row.querySelector('td[colspan]')) {
return;
}
const filename = row.cells[0]?.textContent.toLowerCase() || '';
const title = row.cells[1]?.textContent.toLowerCase() || '';
const matches = filename.includes(term) || title.includes(term);
row.style.display = matches ? '' : 'none';
if (matches) visibleCount++;
});
this.updateResultCount(visibleCount);
}
/**
* Update result count display
*/
updateResultCount(count) {
const countElement = document.getElementById('result-count');
if (countElement) {
countElement.textContent = `${count} config${count !== 1 ? 's' : ''}`;
}
}
/**
* Show error message
*/
showError(message, elementId = 'error-display') {
const errorElement = document.getElementById(elementId);
if (errorElement) {
errorElement.innerHTML = `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle"></i> ${this.escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
errorElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
console.error('Error:', message);
alert('Error: ' + message);
}
}
/**
* Show success message
*/
showSuccess(message, elementId = 'success-display') {
const successElement = document.getElementById(elementId);
if (successElement) {
successElement.innerHTML = `
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle"></i> ${this.escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
successElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
console.log('Success:', message);
}
}
/**
* Clear all messages
*/
clearMessages() {
const elements = ['error-display', 'success-display', 'csv-errors', 'yaml-errors'];
elements.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = '';
}
});
}
/**
* Format file size for display
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize global config manager instance
const configManager = new ConfigManager();
/**
* Setup drag-and-drop zone for file uploads
*/
function setupDropzone(dropzoneId, fileInputId, fileType, onUploadCallback) {
const dropzone = document.getElementById(dropzoneId);
const fileInput = document.getElementById(fileInputId);
if (!dropzone || !fileInput) {
console.warn(`Dropzone setup failed: missing elements (${dropzoneId}, ${fileInputId})`);
return;
}
// Click to browse
dropzone.addEventListener('click', () => {
fileInput.click();
});
// Drag over
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('dragover');
});
// Drag leave
dropzone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
});
// Drop
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0], fileType, onUploadCallback);
}
});
// File input change
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
if (files.length > 0) {
handleFileUpload(files[0], fileType, onUploadCallback);
}
});
}
/**
* Handle file upload (CSV or YAML)
*/
async function handleFileUpload(file, fileType, callback) {
configManager.clearMessages();
// Validate file type
const extension = file.name.split('.').pop().toLowerCase();
if (fileType === 'csv' && extension !== 'csv') {
configManager.showError('Please upload a CSV file (.csv)', 'csv-errors');
return;
}
if (fileType === 'yaml' && !['yaml', 'yml'].includes(extension)) {
configManager.showError('Please upload a YAML file (.yaml or .yml)', 'yaml-errors');
return;
}
// Validate file size (2MB limit for configs)
const maxSize = 2 * 1024 * 1024; // 2MB
if (file.size > maxSize) {
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
configManager.showError(`File too large (${configManager.formatFileSize(file.size)}). Maximum size is 2MB.`, errorId);
return;
}
// Call the provided callback
if (callback) {
try {
await callback(file);
} catch (error) {
const errorId = fileType === 'csv' ? 'csv-errors' : 'yaml-errors';
configManager.showError(error.message, errorId);
}
}
}
/**
* Handle CSV upload and preview
*/
async function handleCSVUpload(file) {
try {
// Show loading state
const previewPlaceholder = document.getElementById('preview-placeholder');
if (previewPlaceholder) {
previewPlaceholder.innerHTML = '<div class="spinner-border" role="status"><span class="visually-hidden">Loading...</span></div>';
}
// Upload CSV
const result = await configManager.uploadCSV(file);
// Show preview
configManager.showPreview(result.preview, result.filename);
// Show success message
configManager.showSuccess(`CSV uploaded successfully! Preview the generated YAML below.`, 'csv-errors');
} catch (error) {
configManager.hidePreview();
throw error;
}
}
/**
* Handle YAML upload
*/
async function handleYAMLUpload(file) {
try {
// Upload YAML
const result = await configManager.uploadYAML(file);
// Show success and redirect
configManager.showSuccess(`Configuration "${result.filename}" uploaded successfully!`, 'yaml-errors');
// Redirect to configs list after 2 seconds
setTimeout(() => {
window.location.href = '/configs';
}, 2000);
} catch (error) {
throw error;
}
}
/**
* Save the previewed configuration (after CSV upload)
*/
async function savePreviewedConfig() {
if (!configManager.currentPreview || !configManager.currentFilename) {
configManager.showError('No configuration to save', 'csv-errors');
return;
}
try {
// The config is already saved during CSV upload, just redirect
configManager.showSuccess(`Configuration "${configManager.currentFilename}" saved successfully!`, 'csv-errors');
// Redirect to configs list after 2 seconds
setTimeout(() => {
window.location.href = '/configs';
}, 2000);
} catch (error) {
configManager.showError('Failed to save configuration: ' + error.message, 'csv-errors');
}
}

View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SneakyScanner{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<!-- Custom CSS (extracted from inline) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<!-- Chart.js for visualizations -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Chart.js Dark Theme Configuration -->
<script>
// Configure Chart.js defaults for dark theme
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#e2e8f0';
Chart.defaults.borderColor = '#334155';
Chart.defaults.backgroundColor = '#1e293b';
}
</script>
{% block extra_styles %}{% endblock %}
</head>
<body>
{% if not hide_nav %}
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
SneakyScanner
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}"
href="{{ url_for('main.dashboard') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
href="{{ url_for('main.scans') }}">Scans</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.schedules') }}">Schedules</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'config' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.configs') }}">Configs</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mt-3" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<div class="footer">
<div class="container-fluid">
SneakyScanner v1.0 - Phase 3 In Progress
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,263 @@
{% extends "base.html" %}
{% block title %}Edit Config - SneakyScanner{% endblock %}
{% block extra_styles %}
<!-- CodeMirror CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
<style>
.config-editor-container {
background: #1e293b;
border-radius: 8px;
padding: 1.5rem;
margin-top: 2rem;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.CodeMirror {
height: 600px;
border: 1px solid #334155;
border-radius: 4px;
font-size: 14px;
background: #0f172a;
}
.editor-actions {
margin-top: 1.5rem;
display: flex;
gap: 1rem;
}
.validation-feedback {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
display: none;
}
.validation-feedback.success {
background: #065f46;
border: 1px solid #10b981;
color: #d1fae5;
}
.validation-feedback.error {
background: #7f1d1d;
border: 1px solid #ef4444;
color: #fee2e2;
}
.back-link {
color: #94a3b8;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.back-link:hover {
color: #cbd5e1;
}
</style>
{% endblock %}
{% block content %}
<div class="container-lg mt-4">
<a href="{{ url_for('main.configs') }}" class="back-link">
<i class="bi bi-arrow-left"></i> Back to Configs
</a>
<h2>Edit Configuration</h2>
<p class="text-muted">Edit the YAML configuration for <strong>{{ filename }}</strong></p>
<div class="config-editor-container">
<div class="editor-header">
<h5 class="mb-0">
<i class="bi bi-file-earmark-code"></i> YAML Editor
</h5>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="validateConfig()">
<i class="bi bi-check-circle"></i> Validate
</button>
</div>
<textarea id="yaml-editor">{{ content }}</textarea>
<div class="validation-feedback" id="validation-feedback"></div>
<div class="editor-actions">
<button type="button" class="btn btn-primary" onclick="saveConfig()">
<i class="bi bi-save"></i> Save Changes
</button>
<button type="button" class="btn btn-secondary" onclick="resetEditor()">
<i class="bi bi-arrow-counterclockwise"></i> Reset
</button>
<a href="{{ url_for('main.configs') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</div>
</div>
<!-- Success Modal -->
<div class="modal fade" id="successModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title">
<i class="bi bi-check-circle-fill"></i> Success
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
Configuration updated successfully!
</div>
<div class="modal-footer">
<a href="{{ url_for('main.configs') }}" class="btn btn-success">
Back to Configs
</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Continue Editing
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- CodeMirror JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/yaml/yaml.min.js"></script>
<script>
// Initialize CodeMirror editor
const editor = CodeMirror.fromTextArea(document.getElementById('yaml-editor'), {
mode: 'yaml',
theme: 'dracula',
lineNumbers: true,
lineWrapping: true,
indentUnit: 2,
tabSize: 2,
indentWithTabs: false,
extraKeys: {
"Tab": function(cm) {
cm.replaceSelection(" ", "end");
}
}
});
// Store original content for reset
const originalContent = editor.getValue();
// Validation function
async function validateConfig() {
const feedback = document.getElementById('validation-feedback');
const content = editor.getValue();
try {
// Basic YAML syntax check (client-side)
// Just check for common YAML issues
if (content.trim() === '') {
showFeedback('error', 'Configuration cannot be empty');
return false;
}
// Check for basic structure
if (!content.includes('title:')) {
showFeedback('error', 'Missing required field: title');
return false;
}
if (!content.includes('sites:')) {
showFeedback('error', 'Missing required field: sites');
return false;
}
showFeedback('success', 'Configuration appears valid. Click "Save Changes" to save.');
return true;
} catch (error) {
showFeedback('error', 'Validation error: ' + error.message);
return false;
}
}
// Save configuration
async function saveConfig() {
const content = editor.getValue();
const filename = '{{ filename }}';
// Show loading state
const saveBtn = event.target;
const originalText = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
try {
const response = await fetch(`/api/configs/${filename}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: content })
});
const data = await response.json();
if (response.ok) {
// Show success modal
const modal = new bootstrap.Modal(document.getElementById('successModal'));
modal.show();
} else {
// Show error feedback
showFeedback('error', data.message || 'Failed to save configuration');
}
} catch (error) {
showFeedback('error', 'Network error: ' + error.message);
} finally {
// Restore button state
saveBtn.disabled = false;
saveBtn.innerHTML = originalText;
}
}
// Reset editor to original content
function resetEditor() {
if (confirm('Are you sure you want to reset all changes?')) {
editor.setValue(originalContent);
hideFeedback();
}
}
// Show validation feedback
function showFeedback(type, message) {
const feedback = document.getElementById('validation-feedback');
feedback.className = `validation-feedback ${type}`;
feedback.innerHTML = `
<i class="bi bi-${type === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill'}"></i>
${message}
`;
feedback.style.display = 'block';
}
// Hide validation feedback
function hideFeedback() {
const feedback = document.getElementById('validation-feedback');
feedback.style.display = 'none';
}
// Auto-validate on content change (debounced)
let validationTimeout;
editor.on('change', function() {
clearTimeout(validationTimeout);
hideFeedback();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,415 @@
{% extends "base.html" %}
{% block title %}Create Configuration - SneakyScanner{% endblock %}
{% block extra_styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
<style>
.file-info {
background-color: #1e293b;
border: 1px solid #334155;
padding: 10px 15px;
border-radius: 5px;
margin-top: 15px;
display: none;
}
.file-info-name {
color: #60a5fa;
font-weight: bold;
}
.file-info-size {
color: #94a3b8;
font-size: 0.9em;
}
</style>
{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Create New Configuration</h1>
<a href="{{ url_for('main.configs') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Configs
</a>
</div>
</div>
</div>
<!-- Upload Tabs -->
<div class="row">
<div class="col-12">
<ul class="nav nav-tabs mb-4" id="uploadTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="cidr-tab" data-bs-toggle="tab" data-bs-target="#cidr"
type="button" role="tab" style="color: #60a5fa;">
<i class="bi bi-diagram-3"></i> Create from CIDR
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="yaml-tab" data-bs-toggle="tab" data-bs-target="#yaml"
type="button" role="tab" style="color: #60a5fa;">
<i class="bi bi-filetype-yml"></i> Upload YAML
</button>
</li>
</ul>
<div class="tab-content" id="uploadTabsContent">
<!-- CIDR Form Tab -->
<div class="tab-pane fade show active" id="cidr" role="tabpanel">
<div class="row">
<div class="col-lg-8 offset-lg-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-diagram-3"></i> Create Configuration from CIDR Range
</h5>
</div>
<div class="card-body">
<p class="text-muted">
<i class="bi bi-info-circle"></i>
Specify a CIDR range to automatically generate a configuration for all IPs in that range.
You can edit the configuration afterwards to add expected ports and services.
</p>
<form id="cidr-form">
<div class="mb-3">
<label for="config-title" class="form-label" style="color: #94a3b8;">
Config Title <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="config-title"
placeholder="e.g., Production Infrastructure Scan" required>
<div class="form-text">A descriptive title for your scan configuration</div>
</div>
<div class="mb-3">
<label for="cidr-range" class="form-label" style="color: #94a3b8;">
CIDR Range <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="cidr-range"
placeholder="e.g., 10.0.0.0/24 or 192.168.1.0/28" required>
<div class="form-text">
Enter a CIDR range (e.g., 10.0.0.0/24 for 254 hosts).
Maximum 10,000 addresses per range.
</div>
</div>
<div class="mb-3">
<label for="site-name" class="form-label" style="color: #94a3b8;">
Site Name (optional)
</label>
<input type="text" class="form-control" id="site-name"
placeholder="e.g., Production Servers">
<div class="form-text">
Logical grouping name for these IPs (default: "Site 1")
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ping-default">
<label class="form-check-label" for="ping-default" style="color: #94a3b8;">
Expect ping response by default
</label>
</div>
<div class="form-text">
Sets the default expectation for ICMP ping responses from these IPs
</div>
</div>
<div id="cidr-errors" class="alert alert-danger" style="display:none;">
<strong>Error:</strong> <span id="cidr-error-message"></span>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-plus-circle"></i> Create Configuration
</button>
</div>
</form>
<div id="cidr-success" class="alert alert-success mt-3" style="display:none;">
<i class="bi bi-check-circle-fill"></i>
<strong>Success!</strong> Configuration created: <span id="cidr-created-filename"></span>
<div class="mt-2">
<a href="#" id="edit-new-config-link" class="btn btn-sm btn-outline-success">
<i class="bi bi-pencil"></i> Edit Now
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- YAML Upload Tab -->
<div class="tab-pane fade" id="yaml" role="tabpanel">
<div class="row">
<div class="col-lg-8 offset-lg-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-cloud-upload"></i> Upload YAML Configuration
</h5>
</div>
<div class="card-body">
<p class="text-muted">
<i class="bi bi-info-circle"></i>
For advanced users: upload a YAML config file directly.
</p>
<div id="yaml-dropzone" class="dropzone">
<i class="bi bi-cloud-upload"></i>
<p>Drag & drop YAML file here or click to browse</p>
<input type="file" id="yaml-file-input" accept=".yaml,.yml" hidden>
</div>
<div id="yaml-file-info" class="file-info">
<div class="file-info-name" id="yaml-filename"></div>
<div class="file-info-size" id="yaml-filesize"></div>
</div>
<div class="mt-3">
<label for="yaml-custom-filename" class="form-label" style="color: #94a3b8;">
Custom Filename (optional):
</label>
<input type="text" id="yaml-custom-filename" class="form-control"
placeholder="Leave empty to use uploaded filename">
</div>
<button id="upload-yaml-btn" class="btn btn-primary mt-3" disabled>
<i class="bi bi-upload"></i> Upload YAML
</button>
<div id="yaml-errors" class="alert alert-danger mt-3" style="display:none;">
<strong>Error:</strong> <span id="yaml-error-message"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Success Modal -->
<div class="modal fade" id="successModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #10b981;">
<i class="bi bi-check-circle"></i> Success
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color: #e2e8f0;">Configuration saved successfully!</p>
<p style="color: #60a5fa; font-weight: bold;" id="success-filename"></p>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<a href="{{ url_for('main.configs') }}" class="btn btn-primary">
<i class="bi bi-list"></i> View All Configs
</a>
<button type="button" class="btn btn-success" onclick="location.reload()">
<i class="bi bi-plus-circle"></i> Create Another
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global variables
let yamlFile = null;
// ============== CIDR Form Submission ==============
document.getElementById('cidr-form').addEventListener('submit', async function(e) {
e.preventDefault();
const title = document.getElementById('config-title').value.trim();
const cidr = document.getElementById('cidr-range').value.trim();
const siteName = document.getElementById('site-name').value.trim();
const pingDefault = document.getElementById('ping-default').checked;
// Validate inputs
if (!title) {
showError('cidr', 'Config title is required');
return;
}
if (!cidr) {
showError('cidr', 'CIDR range is required');
return;
}
// Show loading state
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
try {
const response = await fetch('/api/configs/create-from-cidr', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: title,
cidr: cidr,
site_name: siteName || null,
ping_default: pingDefault
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const data = await response.json();
// Hide error, show success
document.getElementById('cidr-errors').style.display = 'none';
document.getElementById('cidr-created-filename').textContent = data.filename;
// Set edit link
document.getElementById('edit-new-config-link').href = `/configs/edit/${data.filename}`;
document.getElementById('cidr-success').style.display = 'block';
// Reset form
e.target.reset();
// Show success modal
showSuccess(data.filename);
} catch (error) {
console.error('Error creating config from CIDR:', error);
showError('cidr', error.message);
} finally {
// Restore button state
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// ============== YAML Upload ==============
// Setup YAML dropzone
const yamlDropzone = document.getElementById('yaml-dropzone');
const yamlFileInput = document.getElementById('yaml-file-input');
yamlDropzone.addEventListener('click', () => yamlFileInput.click());
yamlDropzone.addEventListener('dragover', (e) => {
e.preventDefault();
yamlDropzone.classList.add('dragover');
});
yamlDropzone.addEventListener('dragleave', () => {
yamlDropzone.classList.remove('dragover');
});
yamlDropzone.addEventListener('drop', (e) => {
e.preventDefault();
yamlDropzone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
handleYAMLFile(file);
});
yamlFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
handleYAMLFile(file);
});
// Handle YAML file selection
function handleYAMLFile(file) {
if (!file) return;
// Check file extension
if (!file.name.endsWith('.yaml') && !file.name.endsWith('.yml')) {
showError('yaml', 'Please select a YAML file (.yaml or .yml)');
return;
}
yamlFile = file;
// Show file info
document.getElementById('yaml-filename').textContent = file.name;
document.getElementById('yaml-filesize').textContent = formatFileSize(file.size);
document.getElementById('yaml-file-info').style.display = 'block';
// Enable upload button
document.getElementById('upload-yaml-btn').disabled = false;
document.getElementById('yaml-errors').style.display = 'none';
}
// Upload YAML file
async function uploadYAMLFile() {
if (!yamlFile) return;
try {
const formData = new FormData();
formData.append('file', yamlFile);
const customFilename = document.getElementById('yaml-custom-filename').value.trim();
if (customFilename) {
formData.append('filename', customFilename);
}
const response = await fetch('/api/configs/upload-yaml', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
const data = await response.json();
showSuccess(data.filename);
} catch (error) {
console.error('Error uploading YAML:', error);
showError('yaml', error.message);
}
}
document.getElementById('upload-yaml-btn').addEventListener('click', uploadYAMLFile);
// ============== Helper Functions ==============
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// Show error
function showError(type, message) {
const errorDiv = document.getElementById(`${type}-errors`);
const errorMsg = document.getElementById(`${type}-error-message`);
errorMsg.textContent = message;
errorDiv.style.display = 'block';
}
// Show success
function showSuccess(filename) {
document.getElementById('success-filename').textContent = filename;
new bootstrap.Modal(document.getElementById('successModal')).show();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,377 @@
{% extends "base.html" %}
{% block title %}Configuration Files - SneakyScanner{% endblock %}
{% block extra_styles %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/config-manager.css') }}">
{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Configuration Files</h1>
<div>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create New Config
</a>
</div>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-configs">-</div>
<div class="stat-label">Total Configs</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="configs-in-use">-</div>
<div class="stat-label">In Use by Schedules</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="total-size">-</div>
<div class="stat-label">Total Size</div>
</div>
</div>
</div>
<!-- Configs Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">All Configurations</h5>
<input type="text" id="search-input" class="form-control" style="max-width: 300px;"
placeholder="Search configs...">
</div>
</div>
<div class="card-body">
<div id="configs-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading configurations...</p>
</div>
<div id="configs-error" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<div id="configs-content" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Filename</th>
<th>Title</th>
<th>Created</th>
<th>Size</th>
<th>Used By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="configs-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-file-earmark-text" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No configuration files</h5>
<p class="text-muted">Create your first config to define scan targets</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle"></i> Create Config
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #f87171;">
<i class="bi bi-exclamation-triangle"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color: #e2e8f0;">Are you sure you want to delete the config file:</p>
<p style="color: #60a5fa; font-weight: bold;" id="delete-config-name"></p>
<p style="color: #fbbf24;" id="delete-warning-schedules" style="display: none;">
<i class="bi bi-exclamation-circle"></i>
This config is used by schedules and cannot be deleted.
</p>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
<!-- View Config Modal -->
<div class="modal fade" id="viewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">
<i class="bi bi-file-earmark-code"></i> Config File Details
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<h6 style="color: #94a3b8;">Filename: <span id="view-filename" style="color: #e2e8f0;"></span></h6>
<h6 class="mt-3" style="color: #94a3b8;">Content:</h6>
<pre style="background-color: #0f172a; border: 1px solid #334155; padding: 15px; border-radius: 5px; max-height: 400px; overflow-y: auto;"><code id="view-content" style="color: #e2e8f0;"></code></pre>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<a id="download-link" href="#" class="btn btn-primary">
<i class="bi bi-download"></i> Download
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Global variables
let configsData = [];
let selectedConfigForDeletion = null;
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// Format date
function formatDate(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
return date.toLocaleString();
}
// Load configs from API
async function loadConfigs() {
try {
document.getElementById('configs-loading').style.display = 'block';
document.getElementById('configs-error').style.display = 'none';
document.getElementById('configs-content').style.display = 'none';
const response = await fetch('/api/configs');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
configsData = data.configs || [];
updateStats();
renderConfigs(configsData);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-content').style.display = 'block';
} catch (error) {
console.error('Error loading configs:', error);
document.getElementById('configs-loading').style.display = 'none';
document.getElementById('configs-error').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Update summary stats
function updateStats() {
const totalConfigs = configsData.length;
const configsInUse = configsData.filter(c => c.used_by_schedules && c.used_by_schedules.length > 0).length;
const totalSize = configsData.reduce((sum, c) => sum + (c.size_bytes || 0), 0);
document.getElementById('total-configs').textContent = totalConfigs;
document.getElementById('configs-in-use').textContent = configsInUse;
document.getElementById('total-size').textContent = formatFileSize(totalSize);
}
// Render configs table
function renderConfigs(configs) {
const tbody = document.getElementById('configs-tbody');
const emptyState = document.getElementById('empty-state');
if (configs.length === 0) {
tbody.innerHTML = '';
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
tbody.innerHTML = configs.map(config => {
const usedByBadge = config.used_by_schedules && config.used_by_schedules.length > 0
? `<span class="badge bg-info" title="${config.used_by_schedules.join(', ')}">${config.used_by_schedules.length} schedule(s)</span>`
: '<span class="badge bg-secondary">Not used</span>';
return `
<tr>
<td><code>${config.filename}</code></td>
<td>${config.title || config.filename}</td>
<td>${formatDate(config.created_at)}</td>
<td>${formatFileSize(config.size_bytes || 0)}</td>
<td>${usedByBadge}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" onclick="viewConfig('${config.filename}')" title="View">
<i class="bi bi-eye"></i>
</button>
<a href="/configs/edit/${config.filename}" class="btn btn-outline-info" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/api/configs/${config.filename}/download" class="btn btn-outline-success" title="Download">
<i class="bi bi-download"></i>
</a>
<button class="btn btn-outline-danger" onclick="confirmDelete('${config.filename}', ${config.used_by_schedules.length > 0})" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
// View config details
async function viewConfig(filename) {
try {
const response = await fetch(`/api/configs/${filename}`);
if (!response.ok) {
throw new Error(`Failed to load config: ${response.statusText}`);
}
const data = await response.json();
document.getElementById('view-filename').textContent = data.filename;
document.getElementById('view-content').textContent = data.content;
document.getElementById('download-link').href = `/api/configs/${filename}/download`;
new bootstrap.Modal(document.getElementById('viewModal')).show();
} catch (error) {
console.error('Error viewing config:', error);
alert(`Error: ${error.message}`);
}
}
// Confirm delete
function confirmDelete(filename, isInUse) {
selectedConfigForDeletion = filename;
document.getElementById('delete-config-name').textContent = filename;
const warningDiv = document.getElementById('delete-warning-schedules');
const deleteBtn = document.getElementById('confirm-delete-btn');
if (isInUse) {
warningDiv.style.display = 'block';
deleteBtn.disabled = true;
deleteBtn.classList.add('disabled');
} else {
warningDiv.style.display = 'none';
deleteBtn.disabled = false;
deleteBtn.classList.remove('disabled');
}
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
// Delete config
async function deleteConfig() {
if (!selectedConfigForDeletion) return;
try {
const response = await fetch(`/api/configs/${selectedConfigForDeletion}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
// Hide modal
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
// Reload configs
await loadConfigs();
// Show success message
showAlert('success', `Config "${selectedConfigForDeletion}" deleted successfully`);
} catch (error) {
console.error('Error deleting config:', error);
showAlert('danger', `Error deleting config: ${error.message}`);
}
}
// Show alert
function showAlert(type, message) {
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`;
const container = document.querySelector('.container-fluid');
container.insertAdjacentHTML('afterbegin', alertHtml);
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
bootstrap.Alert.getInstance(alert)?.close();
}
}, 5000);
}
// Search filter
document.getElementById('search-input').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
if (!searchTerm) {
renderConfigs(configsData);
return;
}
const filtered = configsData.filter(config =>
config.filename.toLowerCase().includes(searchTerm) ||
(config.title && config.title.toLowerCase().includes(searchTerm))
);
renderConfigs(filtered);
});
// Setup delete button
document.getElementById('confirm-delete-btn').addEventListener('click', deleteConfig);
// Load configs on page load
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{% endblock %}

View File

@@ -0,0 +1,580 @@
{% extends "base.html" %}
{% block title %}Dashboard - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<h1 class="mb-4" style="color: #60a5fa;">Dashboard</h1>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-scans">-</div>
<div class="stat-label">Total Scans</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="running-scans">-</div>
<div class="stat-label">Running</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="completed-scans">-</div>
<div class="stat-label">Completed</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="failed-scans">-</div>
<div class="stat-label">Failed</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Quick Actions</h5>
</div>
<div class="card-body">
<button class="btn btn-primary btn-lg" onclick="showTriggerScanModal()">
<span id="trigger-btn-text">Run Scan Now</span>
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
<a href="{{ url_for('main.scans') }}" class="btn btn-secondary btn-lg ms-2">View All Scans</a>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary btn-lg ms-2">
<i class="bi bi-calendar-plus"></i> Manage Schedules
</a>
</div>
</div>
</div>
</div>
<!-- Scan Activity Chart -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan Activity (Last 30 Days)</h5>
</div>
<div class="card-body">
<div id="chart-loading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<canvas id="scanTrendChart" height="100" style="display: none;"></canvas>
</div>
</div>
</div>
<!-- Schedules Widget -->
<div class="col-md-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">Upcoming Schedules</h5>
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
</div>
<div class="card-body">
<div id="schedules-loading" class="text-center py-4">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="schedules-content" style="display: none;"></div>
<div id="schedules-empty" class="text-muted text-center py-4" style="display: none;">
No schedules configured yet.
<br><br>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-sm btn-primary">Create Schedule</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Scans -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">Recent Scans</h5>
<button class="btn btn-sm btn-secondary" onclick="refreshScans()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
</div>
<div class="card-body">
<div id="scans-loading" class="text-center py-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div id="scans-error" class="alert alert-danger" style="display: none;"></div>
<div id="scans-empty" class="text-center py-4 text-muted" style="display: none;">
No scans found. Click "Run Scan Now" to trigger your first scan.
</div>
<div id="scans-table-container" style="display: none;">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Timestamp</th>
<th>Duration</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="scans-tbody">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
{% if config_files %}
<div class="form-text text-muted">
Select a scan configuration file
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
{% endif %}
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let refreshInterval = null;
// Load initial data when page loads
document.addEventListener('DOMContentLoaded', function() {
refreshScans();
loadStats();
loadScanTrend();
loadSchedules();
// Auto-refresh every 10 seconds if there are running scans
refreshInterval = setInterval(function() {
const runningCount = parseInt(document.getElementById('running-scans').textContent);
if (runningCount > 0) {
refreshScans();
loadStats();
}
}, 10000);
// Refresh schedules every 30 seconds
setInterval(loadSchedules, 30000);
});
// Load dashboard stats
async function loadStats() {
try {
const response = await fetch('/api/scans?per_page=1000');
if (!response.ok) {
throw new Error('Failed to load stats');
}
const data = await response.json();
const scans = data.scans || [];
document.getElementById('total-scans').textContent = scans.length;
document.getElementById('running-scans').textContent = scans.filter(s => s.status === 'running').length;
document.getElementById('completed-scans').textContent = scans.filter(s => s.status === 'completed').length;
document.getElementById('failed-scans').textContent = scans.filter(s => s.status === 'failed').length;
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Refresh scans list
async function refreshScans() {
const loadingEl = document.getElementById('scans-loading');
const errorEl = document.getElementById('scans-error');
const emptyEl = document.getElementById('scans-empty');
const tableEl = document.getElementById('scans-table-container');
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
emptyEl.style.display = 'none';
tableEl.style.display = 'none';
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
try {
const response = await fetch('/api/scans?per_page=10&page=1');
if (!response.ok) {
throw new Error('Failed to load scans');
}
const data = await response.json();
const scans = data.scans || [];
loadingEl.style.display = 'none';
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
if (scans.length === 0) {
emptyEl.style.display = 'block';
} else {
tableEl.style.display = 'block';
renderScansTable(scans);
}
} catch (error) {
console.error('Error loading scans:', error);
loadingEl.style.display = 'none';
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
errorEl.textContent = 'Failed to load scans. Please try again.';
errorEl.style.display = 'block';
}
}
// Render scans table
function renderScansTable(scans) {
const tbody = document.getElementById('scans-tbody');
tbody.innerHTML = '';
scans.forEach(scan => {
const row = document.createElement('tr');
row.classList.add('scan-row'); // Fix white row bug
// Format timestamp
const timestamp = new Date(scan.timestamp).toLocaleString();
// Format duration
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
row.innerHTML = `
<td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td>
<td>${statusBadge}</td>
<td>
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
${scan.status !== 'running' ? `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>` : ''}
</td>
`;
tbody.appendChild(row);
});
}
// Show trigger scan modal
function showTriggerScanModal() {
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
document.getElementById('trigger-error').style.display = 'none';
document.getElementById('trigger-scan-form').reset();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configFile = document.getElementById('config-file').value;
const errorEl = document.getElementById('trigger-error');
const btnText = document.getElementById('modal-trigger-text');
const btnSpinner = document.getElementById('modal-trigger-spinner');
if (!configFile) {
errorEl.textContent = 'Please enter a config file path.';
errorEl.style.display = 'block';
return;
}
// Show loading state
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
errorEl.style.display = 'none';
try {
const response = await fetch('/api/scans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_file: configFile
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || data.error || 'Failed to trigger scan');
}
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
// Show success message
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.innerHTML = `
Scan triggered successfully! (ID: ${data.scan_id})
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the beginning of container-fluid
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Refresh scans and stats
refreshScans();
loadStats();
} catch (error) {
console.error('Error triggering scan:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
} finally {
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
}
// Load scan trend chart
async function loadScanTrend() {
const chartLoading = document.getElementById('chart-loading');
const canvas = document.getElementById('scanTrendChart');
try {
const response = await fetch('/api/stats/scan-trend?days=30');
if (!response.ok) {
throw new Error('Failed to load trend data');
}
const data = await response.json();
// Hide loading, show chart
chartLoading.style.display = 'none';
canvas.style.display = 'block';
// Create chart
const ctx = canvas.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Scans per Day',
data: data.values,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(context) {
return new Date(context[0].label).toLocaleDateString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: '#94a3b8'
},
grid: {
color: '#334155'
}
},
x: {
ticks: {
color: '#94a3b8',
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 10
},
grid: {
color: '#334155'
}
}
}
}
});
} catch (error) {
console.error('Error loading chart:', error);
chartLoading.innerHTML = '<p class="text-muted">Failed to load chart data</p>';
}
}
// Load upcoming schedules
async function loadSchedules() {
const loadingEl = document.getElementById('schedules-loading');
const contentEl = document.getElementById('schedules-content');
const emptyEl = document.getElementById('schedules-empty');
try {
const response = await fetch('/api/schedules?per_page=5');
if (!response.ok) {
throw new Error('Failed to load schedules');
}
const data = await response.json();
const schedules = data.schedules || [];
loadingEl.style.display = 'none';
if (schedules.length === 0) {
emptyEl.style.display = 'block';
} else {
contentEl.style.display = 'block';
// Filter enabled schedules and sort by next_run
const enabledSchedules = schedules
.filter(s => s.enabled && s.next_run)
.sort((a, b) => new Date(a.next_run) - new Date(b.next_run))
.slice(0, 3);
if (enabledSchedules.length === 0) {
contentEl.innerHTML = '<p class="text-muted">No enabled schedules</p>';
} else {
contentEl.innerHTML = enabledSchedules.map(schedule => {
const nextRun = new Date(schedule.next_run);
const now = new Date();
const diffMs = nextRun - now;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
let timeStr;
if (diffMins < 1) {
timeStr = 'In less than 1 minute';
} else if (diffMins < 60) {
timeStr = `In ${diffMins} minute${diffMins === 1 ? '' : 's'}`;
} else if (diffHours < 24) {
timeStr = `In ${diffHours} hour${diffHours === 1 ? '' : 's'}`;
} else if (diffDays < 7) {
timeStr = `In ${diffDays} day${diffDays === 1 ? '' : 's'}`;
} else {
timeStr = nextRun.toLocaleDateString();
}
return `
<div class="mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${schedule.name}</strong>
<br>
<small class="text-muted">${timeStr}</small>
<br>
<small class="text-muted mono">${schedule.cron_expression}</small>
</div>
</div>
</div>
`;
}).join('');
}
}
} catch (error) {
console.error('Error loading schedules:', error);
loadingEl.style.display = 'none';
contentEl.style.display = 'block';
contentEl.innerHTML = '<p class="text-muted">Failed to load schedules</p>';
}
}
// Delete scan
async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete scan');
}
// Refresh scans and stats
refreshScans();
loadStats();
} catch (error) {
console.error('Error deleting scan:', error);
alert('Failed to delete scan. Please try again.');
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>400 - Bad Request | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<div class="error-code">400</div>
<h1 class="error-title">Bad Request</h1>
<p class="error-message">
The request could not be understood or was missing required parameters.
<br>
Please check your input and try again.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>401 - Unauthorized | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🔒</div>
<div class="error-code">401</div>
<h1 class="error-title">Unauthorized</h1>
<p class="error-message">
You need to be authenticated to access this page.
<br>
Please log in to continue.
</p>
<a href="/auth/login" class="btn btn-primary">Go to Login</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>403 - Forbidden | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🚫</div>
<div class="error-code">403</div>
<h1 class="error-title">Forbidden</h1>
<p class="error-message">
You don't have permission to access this resource.
<br>
If you think this is an error, please contact the administrator.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #60a5fa;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🔍</div>
<div class="error-code">404</div>
<h1 class="error-title">Page Not Found</h1>
<p class="error-message">
The page you're looking for doesn't exist or has been moved.
<br>
Let's get you back on track.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>405 - Method Not Allowed | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #f59e0b;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">🚧</div>
<div class="error-code">405</div>
<h1 class="error-title">Method Not Allowed</h1>
<p class="error-message">
The HTTP method used is not allowed for this endpoint.
<br>
Please check the API documentation for valid methods.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - Internal Server Error | SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.error-code {
font-size: 8rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
.error-title {
font-size: 2rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 1rem;
}
.error-message {
font-size: 1.1rem;
color: #94a3b8;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 500;
border-radius: 0.5rem;
transition: all 0.3s;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.2);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-details {
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
text-align: left;
}
.error-details-title {
font-size: 0.9rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 0.5rem;
}
.error-details-text {
font-size: 0.85rem;
color: #64748b;
font-family: 'Courier New', monospace;
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-icon">⚠️</div>
<div class="error-code">500</div>
<h1 class="error-title">Internal Server Error</h1>
<p class="error-message">
Something went wrong on our end. We've logged the error and will look into it.
<br>
Please try again in a few moments.
</p>
<a href="/" class="btn btn-primary">Go to Dashboard</a>
<div class="error-details">
<div class="error-details-title">Error Information:</div>
<div class="error-details-text">
An unexpected error occurred while processing your request. Our team has been notified and is working to resolve the issue.
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Login - SneakyScanner{% endblock %}
{% set hide_nav = true %}
{% block content %}
<div class="login-card">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="brand-subtitle">Network Security Scanner</p>
</div>
{% if password_not_set %}
<div class="alert alert-warning">
<strong>Setup Required:</strong> Please set an application password first.
<a href="{{ url_for('auth.setup') }}" class="alert-link">Go to Setup</a>
</div>
{% else %}
<form method="post" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control form-control-lg"
id="password"
name="password"
required
autofocus
placeholder="Enter your password">
</div>
<div class="mb-3 form-check">
<input type="checkbox"
class="form-check-input"
id="remember"
name="remember">
<label class="form-check-label" for="remember">
Remember me
</label>
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Login
</button>
</form>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,545 @@
{% extends "base.html" %}
{% block title %}Compare Scans - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
← Back to All Scans
</a>
<h1 style="color: #60a5fa;">Scan Comparison</h1>
<p class="text-muted">Comparing Scan #{{ scan_id1 }} vs Scan #{{ scan_id2 }}</p>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="comparison-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading comparison...</p>
</div>
<!-- Error State -->
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
<!-- Config Warning -->
<div id="config-warning" class="alert alert-warning" style="display: none;">
<i class="bi bi-exclamation-triangle"></i>
<strong>Different Configurations Detected</strong>
<p class="mb-0" id="config-warning-message"></p>
</div>
<!-- Comparison Content -->
<div id="comparison-content" style="display: none;">
<!-- Drift Score Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Infrastructure Drift Analysis</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3">
<div class="text-center">
<div class="display-4 mb-2" id="drift-score" style="color: #60a5fa;">-</div>
<div class="text-muted">Drift Score</div>
<small class="text-muted d-block mt-1">(0.0 = identical, 1.0 = completely different)</small>
</div>
</div>
<div class="col-md-9">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
<div id="scan1-title" class="fw-bold">-</div>
<small class="text-muted d-block" id="scan1-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan1-config">-</span></small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
<div id="scan2-title" class="fw-bold">-</div>
<small class="text-muted d-block" id="scan2-timestamp">-</small>
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan2-config">-</span></small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label text-muted">Quick Actions</label>
<div>
<a href="/scans/{{ scan_id1 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id1 }}</a>
<a href="/scans/{{ scan_id2 }}" class="btn btn-sm btn-secondary">View Scan #{{ scan_id2 }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Ports Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-hdd-network"></i> Port Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="ports-added-count">0</div>
<div class="stat-label">Ports Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="ports-removed-count">0</div>
<div class="stat-label">Ports Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card">
<div class="stat-value" id="ports-unchanged-count">0</div>
<div class="stat-label">Ports Unchanged</div>
</div>
</div>
</div>
<!-- Added Ports -->
<div id="ports-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Ports -->
<div id="ports-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Ports</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
</tr>
</thead>
<tbody id="ports-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Services Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-gear"></i> Service Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="services-added-count">0</div>
<div class="stat-label">Services Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="services-removed-count">0</div>
<div class="stat-label">Services Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="services-changed-count">0</div>
<div class="stat-label">Services Changed</div>
</div>
</div>
</div>
<!-- Changed Services -->
<div id="services-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Service</th>
<th>New Service</th>
<th>Old Version</th>
<th>New Version</th>
</tr>
</thead>
<tbody id="services-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added Services -->
<div id="services-added-section" style="display: none;">
<h6 class="text-success mb-2"><i class="bi bi-plus-circle"></i> Added Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-added-tbody"></tbody>
</table>
</div>
</div>
<!-- Removed Services -->
<div id="services-removed-section" style="display: none;">
<h6 class="text-danger mb-2"><i class="bi bi-dash-circle"></i> Removed Services</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP Address</th>
<th>Port</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
</tr>
</thead>
<tbody id="services-removed-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Certificates Comparison -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-shield-lock"></i> Certificate Changes
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<div class="stat-card" style="background-color: #065f46; border-color: #6ee7b7;">
<div class="stat-value" id="certs-added-count">0</div>
<div class="stat-label">Certificates Added</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #7f1d1d; border-color: #fca5a5;">
<div class="stat-value" id="certs-removed-count">0</div>
<div class="stat-label">Certificates Removed</div>
</div>
</div>
<div class="col-md-4">
<div class="stat-card" style="background-color: #78350f; border-color: #fcd34d;">
<div class="stat-value" id="certs-changed-count">0</div>
<div class="stat-label">Certificates Changed</div>
</div>
</div>
</div>
<!-- Changed Certificates -->
<div id="certs-changed-section" style="display: none;">
<h6 class="text-warning mb-2"><i class="bi bi-arrow-left-right"></i> Changed Certificates</h6>
<div class="table-responsive mb-3">
<table class="table table-sm">
<thead>
<tr>
<th>IP:Port</th>
<th>Old Subject</th>
<th>New Subject</th>
<th>Old Expiry</th>
<th>New Expiry</th>
</tr>
</thead>
<tbody id="certs-changed-tbody"></tbody>
</table>
</div>
</div>
<!-- Added/Removed Certificates (shown if any) -->
<div id="certs-added-removed-info" style="display: none;">
<p class="text-muted mb-0">
<i class="bi bi-info-circle"></i>
Additional certificate additions and removals correspond to the port changes shown above.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const scanId1 = {{ scan_id1 }};
const scanId2 = {{ scan_id2 }};
// Load comparison data
async function loadComparison() {
const loadingDiv = document.getElementById('comparison-loading');
const errorDiv = document.getElementById('comparison-error');
const contentDiv = document.getElementById('comparison-content');
try {
const response = await fetch(`/api/scans/${scanId1}/compare/${scanId2}`);
if (!response.ok) {
throw new Error('Failed to load comparison');
}
const data = await response.json();
// Hide loading, show content
loadingDiv.style.display = 'none';
contentDiv.style.display = 'block';
// Populate comparison UI
populateComparison(data);
} catch (error) {
console.error('Error loading comparison:', error);
loadingDiv.style.display = 'none';
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.style.display = 'block';
}
}
function populateComparison(data) {
// Show config warning if configs differ
if (data.config_warning) {
const warningDiv = document.getElementById('config-warning');
const warningMessage = document.getElementById('config-warning-message');
warningMessage.textContent = data.config_warning;
warningDiv.style.display = 'block';
}
// Drift score
const driftScore = data.drift_score || 0;
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
// Color code drift score
const driftElement = document.getElementById('drift-score');
if (driftScore < 0.1) {
driftElement.style.color = '#6ee7b7'; // Green - minimal drift
} else if (driftScore < 0.3) {
driftElement.style.color = '#fcd34d'; // Yellow - moderate drift
} else {
driftElement.style.color = '#fca5a5'; // Red - significant drift
}
// Scan metadata
document.getElementById('scan1-id').textContent = data.scan1.id;
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
document.getElementById('scan1-config').textContent = data.scan1.config_file || 'Unknown';
document.getElementById('scan2-id').textContent = data.scan2.id;
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
document.getElementById('scan2-config').textContent = data.scan2.config_file || 'Unknown';
// Ports comparison
populatePortsComparison(data.ports);
// Services comparison
populateServicesComparison(data.services);
// Certificates comparison
populateCertificatesComparison(data.certificates);
}
function populatePortsComparison(ports) {
const addedCount = ports.added.length;
const removedCount = ports.removed.length;
const unchangedCount = ports.unchanged.length;
document.getElementById('ports-added-count').textContent = addedCount;
document.getElementById('ports-removed-count').textContent = removedCount;
document.getElementById('ports-unchanged-count').textContent = unchangedCount;
// Show added ports
if (addedCount > 0) {
document.getElementById('ports-added-section').style.display = 'block';
const tbody = document.getElementById('ports-added-tbody');
tbody.innerHTML = '';
ports.added.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
// Show removed ports
if (removedCount > 0) {
document.getElementById('ports-removed-section').style.display = 'block';
const tbody = document.getElementById('ports-removed-tbody');
tbody.innerHTML = '';
ports.removed.forEach(port => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${port.ip}</td>
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td>${port.state}</td>
`;
tbody.appendChild(row);
});
}
}
function populateServicesComparison(services) {
const addedCount = services.added.length;
const removedCount = services.removed.length;
const changedCount = services.changed.length;
document.getElementById('services-added-count').textContent = addedCount;
document.getElementById('services-removed-count').textContent = removedCount;
document.getElementById('services-changed-count').textContent = changedCount;
// Show changed services
if (changedCount > 0) {
document.getElementById('services-changed-section').style.display = 'block';
const tbody = document.getElementById('services-changed-tbody');
tbody.innerHTML = '';
services.changed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${svc.ip}:${svc.port}</td>
<td>${svc.old.service_name || '-'}</td>
<td class="text-warning">${svc.new.service_name || '-'}</td>
<td>${svc.old.version || '-'}</td>
<td class="text-warning">${svc.new.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show added services
if (addedCount > 0) {
document.getElementById('services-added-section').style.display = 'block';
const tbody = document.getElementById('services-added-tbody');
tbody.innerHTML = '';
services.added.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show removed services
if (removedCount > 0) {
document.getElementById('services-removed-section').style.display = 'block';
const tbody = document.getElementById('services-removed-tbody');
tbody.innerHTML = '';
services.removed.forEach(svc => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td>${svc.ip}</td>
<td class="mono">${svc.port}</td>
<td>${svc.service_name || '-'}</td>
<td>${svc.product || '-'}</td>
<td>${svc.version || '-'}</td>
`;
tbody.appendChild(row);
});
}
}
function populateCertificatesComparison(certs) {
const addedCount = certs.added.length;
const removedCount = certs.removed.length;
const changedCount = certs.changed.length;
document.getElementById('certs-added-count').textContent = addedCount;
document.getElementById('certs-removed-count').textContent = removedCount;
document.getElementById('certs-changed-count').textContent = changedCount;
// Show changed certificates
if (changedCount > 0) {
document.getElementById('certs-changed-section').style.display = 'block';
const tbody = document.getElementById('certs-changed-tbody');
tbody.innerHTML = '';
certs.changed.forEach(cert => {
const row = document.createElement('tr');
row.classList.add('scan-row');
row.innerHTML = `
<td class="mono">${cert.ip}:${cert.port}</td>
<td>${cert.old.subject || '-'}</td>
<td class="text-warning">${cert.new.subject || '-'}</td>
<td>${cert.old.not_valid_after ? new Date(cert.old.not_valid_after).toLocaleDateString() : '-'}</td>
<td class="text-warning">${cert.new.not_valid_after ? new Date(cert.new.not_valid_after).toLocaleDateString() : '-'}</td>
`;
tbody.appendChild(row);
});
}
// Show info if there are added/removed certs
if (addedCount > 0 || removedCount > 0) {
document.getElementById('certs-added-removed-info').style.display = 'block';
}
}
// Load comparison on page load
loadComparison();
</script>
{% endblock %}

View File

@@ -0,0 +1,605 @@
{% extends "base.html" %}
{% block title %}Scan #{{ scan_id }} - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="{{ url_for('main.scans') }}" class="text-muted text-decoration-none mb-2 d-inline-block">
← Back to All Scans
</a>
<h1 style="color: #60a5fa;">Scan #<span id="scan-id">{{ scan_id }}</span></h1>
</div>
<div>
<button class="btn btn-primary" onclick="compareWithPrevious()" id="compare-btn" style="display: none;">
<i class="bi bi-arrow-left-right"></i> Compare with Previous
</button>
<button class="btn btn-secondary ms-2" onclick="refreshScan()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
<button class="btn btn-danger ms-2" onclick="deleteScan()" id="delete-btn">Delete Scan</button>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="scan-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading scan details...</p>
</div>
<!-- Error State -->
<div id="scan-error" class="alert alert-danger" style="display: none;"></div>
<!-- Scan Content -->
<div id="scan-content" style="display: none;">
<!-- Summary Card -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
<label class="form-label text-muted">Title</label>
<div id="scan-title" class="fw-bold">-</div>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label class="form-label text-muted">Timestamp</label>
<div id="scan-timestamp" class="mono">-</div>
</div>
</div>
<div class="col-md-2">
<div class="mb-3">
<label class="form-label text-muted">Duration</label>
<div id="scan-duration" class="mono">-</div>
</div>
</div>
<div class="col-md-2">
<div class="mb-3">
<label class="form-label text-muted">Status</label>
<div id="scan-status">-</div>
</div>
</div>
<div class="col-md-2">
<div class="mb-3">
<label class="form-label text-muted">Triggered By</label>
<div id="scan-triggered-by">-</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="mb-0">
<label class="form-label text-muted">Config File</label>
<div id="scan-config-file" class="mono">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Stats Row -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-sites">0</div>
<div class="stat-label">Sites</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-ips">0</div>
<div class="stat-label">IP Addresses</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-ports">0</div>
<div class="stat-label">Open Ports</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-services">0</div>
<div class="stat-label">Services</div>
</div>
</div>
</div>
<!-- Historical Trend Chart -->
<div class="row mb-4" id="historical-chart-row" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">
<i class="bi bi-graph-up"></i> Port Count History
</h5>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Historical port count trend for scans using the same configuration
</p>
<div style="position: relative; height: 300px;">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Sites and IPs -->
<div id="sites-container">
<!-- Sites will be dynamically inserted here -->
</div>
<!-- Output Files -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Output Files</h5>
</div>
<div class="card-body">
<div id="output-files" class="d-flex gap-2">
<!-- File links will be dynamically inserted here -->
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const scanId = {{ scan_id }};
let scanData = null;
let historyChart = null; // Store chart instance to prevent duplicates
// Load scan on page load
document.addEventListener('DOMContentLoaded', function() {
loadScan().then(() => {
findPreviousScan();
loadHistoricalChart();
});
// Auto-refresh every 10 seconds if scan is running
setInterval(function() {
if (scanData && scanData.status === 'running') {
loadScan();
}
}, 10000);
});
// Load scan details
async function loadScan() {
const loadingEl = document.getElementById('scan-loading');
const errorEl = document.getElementById('scan-error');
const contentEl = document.getElementById('scan-content');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
contentEl.style.display = 'none';
try {
const response = await fetch(`/api/scans/${scanId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Scan not found');
}
throw new Error('Failed to load scan');
}
scanData = await response.json();
loadingEl.style.display = 'none';
contentEl.style.display = 'block';
renderScan(scanData);
} catch (error) {
console.error('Error loading scan:', error);
loadingEl.style.display = 'none';
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
// Render scan details
function renderScan(scan) {
// Summary
document.getElementById('scan-title').textContent = scan.title || 'Untitled Scan';
document.getElementById('scan-timestamp').textContent = new Date(scan.timestamp).toLocaleString();
document.getElementById('scan-duration').textContent = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
document.getElementById('scan-triggered-by').textContent = scan.triggered_by || 'manual';
document.getElementById('scan-config-file').textContent = scan.config_file || '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
document.getElementById('delete-btn').disabled = true;
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
document.getElementById('scan-status').innerHTML = statusBadge;
// Stats
const sites = scan.sites || [];
let totalIps = 0;
let totalPorts = 0;
let totalServices = 0;
sites.forEach(site => {
const ips = site.ips || [];
totalIps += ips.length;
ips.forEach(ip => {
const ports = ip.ports || [];
totalPorts += ports.length;
ports.forEach(port => {
totalServices += (port.services || []).length;
});
});
});
document.getElementById('total-sites').textContent = sites.length;
document.getElementById('total-ips').textContent = totalIps;
document.getElementById('total-ports').textContent = totalPorts;
document.getElementById('total-services').textContent = totalServices;
// Sites
renderSites(sites);
// Output files
renderOutputFiles(scan);
}
// Render sites
function renderSites(sites) {
const container = document.getElementById('sites-container');
container.innerHTML = '';
sites.forEach((site, siteIdx) => {
const siteCard = document.createElement('div');
siteCard.className = 'row mb-4';
siteCard.innerHTML = `
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">${site.name}</h5>
</div>
<div class="card-body">
<div id="site-${siteIdx}-ips"></div>
</div>
</div>
</div>
`;
container.appendChild(siteCard);
// Render IPs for this site
const ipsContainer = document.getElementById(`site-${siteIdx}-ips`);
const ips = site.ips || [];
ips.forEach((ip, ipIdx) => {
const ipDiv = document.createElement('div');
ipDiv.className = 'mb-3';
ipDiv.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mono mb-0">${ip.address}</h6>
<div>
${ip.ping_actual ? '<span class="badge badge-success">Ping: Responsive</span>' : '<span class="badge badge-danger">Ping: No Response</span>'}
</div>
</div>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>State</th>
<th>Service</th>
<th>Product</th>
<th>Version</th>
<th>Status</th>
</tr>
</thead>
<tbody id="site-${siteIdx}-ip-${ipIdx}-ports"></tbody>
</table>
</div>
`;
ipsContainer.appendChild(ipDiv);
// Render ports for this IP
const portsContainer = document.getElementById(`site-${siteIdx}-ip-${ipIdx}-ports`);
const ports = ip.ports || [];
if (ports.length === 0) {
portsContainer.innerHTML = '<tr class="scan-row"><td colspan="7" class="text-center text-muted">No ports found</td></tr>';
} else {
ports.forEach(port => {
const service = port.services && port.services.length > 0 ? port.services[0] : null;
const row = document.createElement('tr');
row.classList.add('scan-row'); // Fix white row bug
row.innerHTML = `
<td class="mono">${port.port}</td>
<td>${port.protocol.toUpperCase()}</td>
<td><span class="badge badge-success">${port.state || 'open'}</span></td>
<td>${service ? service.service_name : '-'}</td>
<td>${service ? service.product || '-' : '-'}</td>
<td class="mono">${service ? service.version || '-' : '-'}</td>
<td>${port.expected ? '<span class="badge badge-good">Expected</span>' : '<span class="badge badge-warning">Unexpected</span>'}</td>
`;
portsContainer.appendChild(row);
});
}
});
});
}
// Render output files
function renderOutputFiles(scan) {
const container = document.getElementById('output-files');
container.innerHTML = '';
const files = [];
if (scan.json_path) {
files.push({ label: 'JSON', path: scan.json_path, icon: '📄' });
}
if (scan.html_path) {
files.push({ label: 'HTML Report', path: scan.html_path, icon: '🌐' });
}
if (scan.zip_path) {
files.push({ label: 'ZIP Archive', path: scan.zip_path, icon: '📦' });
}
if (files.length === 0) {
container.innerHTML = '<p class="text-muted mb-0">No output files generated yet.</p>';
} else {
files.forEach(file => {
const link = document.createElement('a');
link.href = `/output/${file.path.split('/').pop()}`;
link.className = 'btn btn-secondary';
link.target = '_blank';
link.innerHTML = `${file.icon} ${file.label}`;
container.appendChild(link);
});
}
}
// Refresh scan
function refreshScan() {
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
loadScan().finally(() => {
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
});
}
// Delete scan
async function deleteScan() {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
// Disable delete button to prevent double-clicks
const deleteBtn = document.getElementById('delete-btn');
deleteBtn.disabled = true;
deleteBtn.textContent = 'Deleting...';
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
// Check status code first
if (!response.ok) {
// Try to get error message from response
let errorMessage = `HTTP ${response.status}: Failed to delete scan`;
try {
const data = await response.json();
errorMessage = data.message || errorMessage;
} catch (e) {
// Ignore JSON parse errors for error responses
}
throw new Error(errorMessage);
}
// For successful responses, try to parse JSON but don't fail if it doesn't work
try {
await response.json();
} catch (e) {
console.warn('Response is not valid JSON, but deletion succeeded');
}
// Wait 2 seconds to ensure deletion completes fully
await new Promise(resolve => setTimeout(resolve, 2000));
// Redirect to scans list
window.location.href = '{{ url_for("main.scans") }}';
} catch (error) {
console.error('Error deleting scan:', error);
alert(`Failed to delete scan: ${error.message}`);
// Re-enable button on error
deleteBtn.disabled = false;
deleteBtn.textContent = 'Delete Scan';
}
}
// Find previous scan and show compare button
let previousScanId = null;
let currentConfigFile = null;
async function findPreviousScan() {
try {
// Get current scan details first to know which config it used
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
const currentScanData = await currentScanResponse.json();
currentConfigFile = currentScanData.config_file;
// Get list of completed scans
const response = await fetch('/api/scans?per_page=100&status=completed');
const data = await response.json();
if (data.scans && data.scans.length > 0) {
// Find the current scan
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
if (currentScanIndex !== -1) {
// Look for the most recent previous scan with the SAME config file
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
const previousScan = data.scans[i];
// Check if this scan uses the same config
if (previousScan.config_file === currentConfigFile) {
previousScanId = previousScan.id;
// Show the compare button
const compareBtn = document.getElementById('compare-btn');
if (compareBtn) {
compareBtn.style.display = 'inline-block';
compareBtn.title = `Compare with Scan #${previousScanId} (same config)`;
}
break; // Found the most recent matching scan
}
}
// If no matching config found, don't show compare button
if (!previousScanId) {
console.log('No previous scans found with the same configuration');
}
}
}
} catch (error) {
console.error('Error finding previous scan:', error);
}
}
// Compare with previous scan
function compareWithPrevious() {
if (previousScanId) {
window.location.href = `/scans/${previousScanId}/compare/${scanId}`;
}
}
// Load historical trend chart
async function loadHistoricalChart() {
try {
const response = await fetch(`/api/stats/scan-history/${scanId}?limit=20`);
const data = await response.json();
// Only show chart if there are multiple scans
if (data.scans && data.scans.length > 1) {
document.getElementById('historical-chart-row').style.display = 'block';
// Destroy existing chart to prevent canvas growth bug
if (historyChart) {
historyChart.destroy();
}
const ctx = document.getElementById('historyChart').getContext('2d');
historyChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.labels,
datasets: [{
label: 'Open Ports',
data: data.port_counts,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.3,
fill: true,
pointBackgroundColor: '#60a5fa',
pointBorderColor: '#1e293b',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#1e293b',
titleColor: '#e2e8f0',
bodyColor: '#e2e8f0',
borderColor: '#334155',
borderWidth: 1,
callbacks: {
afterLabel: function(context) {
const scan = data.scans[context.dataIndex];
return `Scan ID: ${scan.id}\nIPs: ${scan.ip_count}`;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: '#94a3b8'
},
grid: {
color: '#334155'
}
},
x: {
ticks: {
color: '#94a3b8',
maxRotation: 45,
minRotation: 45
},
grid: {
color: '#334155'
}
}
},
onClick: (event, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
const scan = data.scans[index];
window.location.href = `/scans/${scan.id}`;
}
}
}
});
}
} catch (error) {
console.error('Error loading historical chart:', error);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,487 @@
{% extends "base.html" %}
{% block title %}All Scans - 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;">All Scans</h1>
<button class="btn btn-primary" onclick="showTriggerScanModal()">
<span id="trigger-btn-text">Trigger New Scan</span>
<span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</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">Filter by Status</label>
<select class="form-select" id="status-filter" onchange="filterScans()">
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<div class="col-md-4">
<label for="per-page" class="form-label">Results per Page</label>
<select class="form-select" id="per-page" onchange="changePerPage()">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button class="btn btn-secondary w-100" onclick="refreshScans()">
<span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scans Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan History</h5>
</div>
<div class="card-body">
<div id="scans-loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading scans...</p>
</div>
<div id="scans-error" class="alert alert-danger" style="display: none;"></div>
<div id="scans-empty" class="text-center py-5 text-muted" style="display: none;">
<h5>No scans found</h5>
<p>Click "Trigger New Scan" to create your first scan.</p>
</div>
<div id="scans-table-container" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th>Title</th>
<th style="width: 200px;">Timestamp</th>
<th style="width: 100px;">Duration</th>
<th style="width: 120px;">Status</th>
<th style="width: 200px;">Actions</th>
</tr>
</thead>
<tbody id="scans-tbody">
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Showing <span id="showing-start">0</span> to <span id="showing-end">0</span> of <span id="total-count">0</span> scans
</div>
<nav>
<ul class="pagination mb-0" id="pagination">
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;">
<div class="modal-header" style="border-bottom: 1px solid #334155;">
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="trigger-scan-form">
<div class="mb-3">
<label for="config-file" class="form-label">Config File</label>
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
<option value="">Select a config file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
{% if config_files %}
<div class="form-text text-muted">
Select a scan configuration file
</div>
{% else %}
<div class="alert alert-warning mt-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<strong>No configurations available</strong>
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Create Configuration
</a>
</div>
{% endif %}
</div>
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form>
</div>
<div class="modal-footer" style="border-top: 1px solid #334155;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
<span id="modal-trigger-text">Trigger Scan</span>
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
let perPage = 20;
let statusFilter = '';
let totalCount = 0;
// Load initial data when page loads
document.addEventListener('DOMContentLoaded', function() {
loadScans();
// Auto-refresh every 15 seconds
setInterval(function() {
loadScans();
}, 15000);
});
// Load scans from API
async function loadScans() {
const loadingEl = document.getElementById('scans-loading');
const errorEl = document.getElementById('scans-error');
const emptyEl = document.getElementById('scans-empty');
const tableEl = document.getElementById('scans-table-container');
// Show loading state
loadingEl.style.display = 'block';
errorEl.style.display = 'none';
emptyEl.style.display = 'none';
tableEl.style.display = 'none';
try {
let url = `/api/scans?page=${currentPage}&per_page=${perPage}`;
if (statusFilter) {
url += `&status=${statusFilter}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to load scans');
}
const data = await response.json();
const scans = data.scans || [];
totalCount = data.total || 0;
loadingEl.style.display = 'none';
if (scans.length === 0) {
emptyEl.style.display = 'block';
} else {
tableEl.style.display = 'block';
renderScansTable(scans);
renderPagination(data.page, data.per_page, data.total, data.pages);
}
} catch (error) {
console.error('Error loading scans:', error);
loadingEl.style.display = 'none';
errorEl.textContent = 'Failed to load scans. Please try again.';
errorEl.style.display = 'block';
}
}
// Render scans table
function renderScansTable(scans) {
const tbody = document.getElementById('scans-tbody');
tbody.innerHTML = '';
scans.forEach(scan => {
const row = document.createElement('tr');
row.classList.add('scan-row'); // Fix white row bug
// Format timestamp
const timestamp = new Date(scan.timestamp).toLocaleString();
// Format duration
const duration = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
// Status badge
let statusBadge = '';
if (scan.status === 'completed') {
statusBadge = '<span class="badge badge-success">Completed</span>';
} else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>';
} else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
}
row.innerHTML = `
<td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td>
<td>${statusBadge}</td>
<td>
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
${scan.status !== 'running' ? `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>` : ''}
</td>
`;
tbody.appendChild(row);
});
}
// Render pagination
function renderPagination(page, per_page, total, pages) {
const paginationEl = document.getElementById('pagination');
paginationEl.innerHTML = '';
// Update showing text
const start = (page - 1) * per_page + 1;
const end = Math.min(page * per_page, total);
document.getElementById('showing-start').textContent = start;
document.getElementById('showing-end').textContent = end;
document.getElementById('total-count').textContent = total;
if (pages <= 1) {
return;
}
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${page - 1}); return false;">Previous</a>`;
paginationEl.appendChild(prevLi);
// Page numbers
const maxPagesToShow = 5;
let startPage = Math.max(1, page - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(pages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
if (startPage > 1) {
const firstLi = document.createElement('li');
firstLi.className = 'page-item';
firstLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(1); return false;">1</a>`;
paginationEl.appendChild(firstLi);
if (startPage > 2) {
const ellipsisLi = document.createElement('li');
ellipsisLi.className = 'page-item disabled';
ellipsisLi.innerHTML = '<a class="page-link" href="#">...</a>';
paginationEl.appendChild(ellipsisLi);
}
}
for (let i = startPage; i <= endPage; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${i}); return false;">${i}</a>`;
paginationEl.appendChild(pageLi);
}
if (endPage < pages) {
if (endPage < pages - 1) {
const ellipsisLi = document.createElement('li');
ellipsisLi.className = 'page-item disabled';
ellipsisLi.innerHTML = '<a class="page-link" href="#">...</a>';
paginationEl.appendChild(ellipsisLi);
}
const lastLi = document.createElement('li');
lastLi.className = 'page-item';
lastLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${pages}); return false;">${pages}</a>`;
paginationEl.appendChild(lastLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${page === pages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" onclick="goToPage(${page + 1}); return false;">Next</a>`;
paginationEl.appendChild(nextLi);
}
// Navigation functions
function goToPage(page) {
currentPage = page;
loadScans();
}
function filterScans() {
statusFilter = document.getElementById('status-filter').value;
currentPage = 1;
loadScans();
}
function changePerPage() {
perPage = parseInt(document.getElementById('per-page').value);
currentPage = 1;
loadScans();
}
function refreshScans() {
const refreshBtn = document.getElementById('refresh-text');
const refreshSpinner = document.getElementById('refresh-spinner');
refreshBtn.style.display = 'none';
refreshSpinner.style.display = 'inline-block';
loadScans().finally(() => {
refreshBtn.style.display = 'inline';
refreshSpinner.style.display = 'none';
});
}
// Show trigger scan modal
function showTriggerScanModal() {
const modal = new bootstrap.Modal(document.getElementById('triggerScanModal'));
document.getElementById('trigger-error').style.display = 'none';
document.getElementById('trigger-scan-form').reset();
modal.show();
}
// Trigger scan
async function triggerScan() {
const configFile = document.getElementById('config-file').value;
const errorEl = document.getElementById('trigger-error');
const btnText = document.getElementById('modal-trigger-text');
const btnSpinner = document.getElementById('modal-trigger-spinner');
if (!configFile) {
errorEl.textContent = 'Please enter a config file path.';
errorEl.style.display = 'block';
return;
}
// Show loading state
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
errorEl.style.display = 'none';
try {
const response = await fetch('/api/scans', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
config_file: configFile
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to trigger scan');
}
const data = await response.json();
// Hide error before closing modal to prevent flash
errorEl.style.display = 'none';
// Close modal
bootstrap.Modal.getInstance(document.getElementById('triggerScanModal')).hide();
// Show success message
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.innerHTML = `
Scan triggered successfully! (ID: ${data.scan_id})
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the beginning of container-fluid
const container = document.querySelector('.container-fluid');
container.insertBefore(alertDiv, container.firstChild);
// Refresh scans
loadScans();
} catch (error) {
console.error('Error triggering scan:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
} finally {
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
}
// Delete scan
async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
return;
}
try {
const response = await fetch(`/api/scans/${scanId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete scan');
}
// Show success message
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show mt-3';
alertDiv.innerHTML = `
Scan ${scanId} deleted successfully.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.container-fluid').insertBefore(alertDiv, document.querySelector('.row'));
// Refresh scans
loadScans();
} catch (error) {
console.error('Error deleting scan:', error);
alert('Failed to delete scan. Please try again.');
}
}
// Custom pagination styles
const style = document.createElement('style');
style.textContent = `
.pagination {
--bs-pagination-bg: #1e293b;
--bs-pagination-border-color: #334155;
--bs-pagination-hover-bg: #334155;
--bs-pagination-hover-border-color: #475569;
--bs-pagination-focus-bg: #334155;
--bs-pagination-active-bg: #3b82f6;
--bs-pagination-active-border-color: #3b82f6;
--bs-pagination-disabled-bg: #0f172a;
--bs-pagination-disabled-border-color: #334155;
--bs-pagination-color: #e2e8f0;
--bs-pagination-hover-color: #e2e8f0;
--bs-pagination-disabled-color: #64748b;
}
`;
document.head.appendChild(style);
</script>
{% endblock %}

View File

@@ -0,0 +1,442 @@
{% extends "base.html" %}
{% block title %}Create Schedule - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Create Schedule</h1>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Schedules
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<form id="create-schedule-form">
<!-- Basic Information Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
</div>
<div class="card-body">
<!-- Schedule Name -->
<div class="mb-3">
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="schedule-name" name="name"
placeholder="e.g., Daily Infrastructure Scan"
required>
<small class="form-text text-muted">A descriptive name for this schedule</small>
</div>
<!-- Config File -->
<div class="mb-3">
<label for="config-file" class="form-label">Configuration File <span class="text-danger">*</span></label>
<select class="form-select" id="config-file" name="config_file" required>
<option value="">Select a configuration file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">The scan configuration to use for this schedule</small>
</div>
<!-- Enable/Disable -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="schedule-enabled"
name="enabled" checked>
<label class="form-check-label" for="schedule-enabled">
Enable schedule immediately
</label>
</div>
<small class="form-text text-muted">If disabled, the schedule will be created but not executed</small>
</div>
</div>
</div>
<!-- Cron Expression Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
</div>
<div class="card-body">
<!-- Quick Templates -->
<div class="mb-3">
<label class="form-label">Quick Templates:</label>
<div class="btn-group-vertical btn-group-sm w-100" role="group">
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
</button>
</div>
</div>
<!-- Manual Cron Entry -->
<div class="mb-3">
<label for="cron-expression" class="form-label">
Cron Expression <span class="text-danger">*</span>
<span class="badge bg-info">LOCAL TIME</span>
</label>
<input type="text" class="form-control font-monospace" id="cron-expression"
name="cron_expression" placeholder="0 2 * * *"
oninput="validateCron()" required>
<small class="form-text text-muted">
Format: <code>minute hour day month weekday</code><br>
<strong class="text-info"> All times use your local timezone (CST/UTC-6)</strong>
</small>
</div>
<!-- Cron Validation Feedback -->
<div id="cron-feedback" class="alert" style="display: none;"></div>
<!-- Human-Readable Description -->
<div id="cron-description-container" style="display: none;">
<div class="alert alert-info">
<strong>Description:</strong>
<div id="cron-description" class="mt-1"></div>
</div>
</div>
<!-- Next Run Times Preview -->
<div id="next-runs-container" style="display: none;">
<label class="form-label">Next 5 execution times (local time):</label>
<ul id="next-runs-list" class="list-group">
<!-- Populated by JavaScript -->
</ul>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between">
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-plus-circle"></i> Create Schedule
</button>
</div>
</div>
</div>
</form>
</div>
<!-- Help Sidebar -->
<div class="col-lg-4">
<div class="card sticky-top" style="top: 20px;">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
</div>
<div class="card-body">
<h6>Field Format:</h6>
<table class="table table-sm">
<thead>
<tr>
<th>Field</th>
<th>Values</th>
</tr>
</thead>
<tbody>
<tr>
<td>Minute</td>
<td>0-59</td>
</tr>
<tr>
<td>Hour</td>
<td>0-23</td>
</tr>
<tr>
<td>Day</td>
<td>1-31</td>
</tr>
<tr>
<td>Month</td>
<td>1-12</td>
</tr>
<tr>
<td>Weekday</td>
<td>0-6 (0=Sunday)</td>
</tr>
</tbody>
</table>
<h6 class="mt-3">Special Characters:</h6>
<ul class="list-unstyled">
<li><code>*</code> - Any value</li>
<li><code>*/n</code> - Every n units</li>
<li><code>1,2,3</code> - Specific values</li>
<li><code>1-5</code> - Range of values</li>
</ul>
<h6 class="mt-3">Examples:</h6>
<ul class="list-unstyled">
<li><code>0 0 * * *</code> - Daily at midnight</li>
<li><code>*/15 * * * *</code> - Every 15 minutes</li>
<li><code>0 9-17 * * 1-5</code> - Hourly, 9am-5pm, Mon-Fri</li>
</ul>
<div class="alert alert-info mt-3">
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
All cron expressions use your <strong>local system time</strong>.<br><br>
<strong>Current local time:</strong> <span id="user-local-time"></span><br>
<strong>Your timezone:</strong> <span id="timezone-offset"></span><br><br>
<small>Schedules will run at the specified time in your local timezone.</small>
</div>
</div>
</div>
</div>
</div>
<script>
// Update local time and timezone info every second
function updateServerTime() {
const now = new Date();
const localTime = now.toLocaleTimeString();
const offset = -now.getTimezoneOffset() / 60;
const offsetStr = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
if (document.getElementById('user-local-time')) {
document.getElementById('user-local-time').textContent = localTime;
}
if (document.getElementById('timezone-offset')) {
document.getElementById('timezone-offset').textContent = offsetStr;
}
}
updateServerTime();
setInterval(updateServerTime, 1000);
// Set cron expression from template button
function setCron(expression) {
document.getElementById('cron-expression').value = expression;
validateCron();
}
// Validate cron expression (client-side basic validation)
function validateCron() {
const input = document.getElementById('cron-expression');
const expression = input.value.trim();
const feedback = document.getElementById('cron-feedback');
const descContainer = document.getElementById('cron-description-container');
const description = document.getElementById('cron-description');
const nextRunsContainer = document.getElementById('next-runs-container');
if (!expression) {
feedback.style.display = 'none';
descContainer.style.display = 'none';
nextRunsContainer.style.display = 'none';
return;
}
// Basic validation: should have 5 fields
const parts = expression.split(/\s+/);
if (parts.length !== 5) {
feedback.className = 'alert alert-danger';
feedback.textContent = 'Invalid format: Cron expression must have exactly 5 fields (minute hour day month weekday)';
feedback.style.display = 'block';
descContainer.style.display = 'none';
nextRunsContainer.style.display = 'none';
return;
}
// Basic field validation
const [minute, hour, day, month, weekday] = parts;
const errors = [];
if (!isValidCronField(minute, 0, 59)) errors.push('minute (0-59)');
if (!isValidCronField(hour, 0, 23)) errors.push('hour (0-23)');
if (!isValidCronField(day, 1, 31)) errors.push('day (1-31)');
if (!isValidCronField(month, 1, 12)) errors.push('month (1-12)');
if (!isValidCronField(weekday, 0, 6)) errors.push('weekday (0-6)');
if (errors.length > 0) {
feedback.className = 'alert alert-danger';
feedback.textContent = 'Invalid fields: ' + errors.join(', ');
feedback.style.display = 'block';
descContainer.style.display = 'none';
nextRunsContainer.style.display = 'none';
return;
}
// Valid expression
feedback.className = 'alert alert-success';
feedback.textContent = 'Valid cron expression';
feedback.style.display = 'block';
// Show human-readable description
description.textContent = describeCron(parts);
descContainer.style.display = 'block';
// Calculate and show next run times
calculateNextRuns(expression);
nextRunsContainer.style.display = 'block';
}
// Validate individual cron field
function isValidCronField(field, min, max) {
if (field === '*') return true;
// Handle ranges: 1-5
if (field.includes('-')) {
const [start, end] = field.split('-').map(Number);
return start >= min && end <= max && start <= end;
}
// Handle steps: */5 or 1-10/2
if (field.includes('/')) {
const [range, step] = field.split('/');
if (range === '*') return Number(step) > 0;
return isValidCronField(range, min, max) && Number(step) > 0;
}
// Handle lists: 1,2,3
if (field.includes(',')) {
return field.split(',').every(v => {
const num = Number(v);
return !isNaN(num) && num >= min && num <= max;
});
}
// Single number
const num = Number(field);
return !isNaN(num) && num >= min && num <= max;
}
// Generate human-readable description
function describeCron(parts) {
const [minute, hour, day, month, weekday] = parts;
// Common patterns
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday === '*') {
return 'Runs daily at midnight (local time)';
}
if (minute === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:00 (local time)`;
}
if (minute !== '*' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
return `Runs daily at ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} (local time)`;
}
if (minute === '0' && hour === '0' && day === '*' && month === '*' && weekday !== '*') {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return `Runs weekly on ${days[Number(weekday)]} at midnight`;
}
if (minute === '0' && hour === '0' && day !== '*' && month === '*' && weekday === '*') {
return `Runs monthly on day ${day} at midnight`;
}
if (minute.startsWith('*/')) {
const interval = minute.split('/')[1];
return `Runs every ${interval} minutes`;
}
if (hour.startsWith('*/') && minute === '0') {
const interval = hour.split('/')[1];
return `Runs every ${interval} hours`;
}
return `Runs at ${minute} ${hour} ${day} ${month} ${weekday} (cron format)`;
}
// Calculate next 5 run times (simplified - server will do actual calculation)
function calculateNextRuns(expression) {
const list = document.getElementById('next-runs-list');
list.innerHTML = '<li class="list-group-item"><em>Will be calculated by server...</em></li>';
// In production, this would call an API endpoint to get accurate next runs
// For now, just show placeholder
}
// Handle form submission
document.getElementById('create-schedule-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submit-btn');
const originalText = submitBtn.innerHTML;
// Get form data
const formData = {
name: document.getElementById('schedule-name').value.trim(),
config_file: document.getElementById('config-file').value,
cron_expression: document.getElementById('cron-expression').value.trim(),
enabled: document.getElementById('schedule-enabled').checked
};
// Validate
if (!formData.name || !formData.config_file || !formData.cron_expression) {
showNotification('Please fill in all required fields', 'warning');
return;
}
// Disable submit button
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
try {
const response = await fetch('/api/schedules', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
showNotification('Schedule created successfully! Redirecting...', 'success');
// Redirect to schedules list
setTimeout(() => {
window.location.href = '/schedules';
}, 1500);
} catch (error) {
console.error('Error creating schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
// Re-enable submit button
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,596 @@
{% extends "base.html" %}
{% block title %}Edit Schedule - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Edit Schedule</h1>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to Schedules
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<!-- Loading State -->
<div id="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading schedule...</p>
</div>
<!-- Error State -->
<div id="error-state" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<!-- Edit Form -->
<form id="edit-schedule-form" style="display: none;">
<input type="hidden" id="schedule-id">
<!-- Basic Information Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Basic Information</h5>
</div>
<div class="card-body">
<!-- Schedule Name -->
<div class="mb-3">
<label for="schedule-name" class="form-label">Schedule Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="schedule-name" name="name"
placeholder="e.g., Daily Infrastructure Scan"
required>
</div>
<!-- Config File (read-only) -->
<div class="mb-3">
<label for="config-file" class="form-label">Configuration File</label>
<input type="text" class="form-control" id="config-file" readonly>
<small class="form-text text-muted">Configuration file cannot be changed after creation</small>
</div>
<!-- Enable/Disable -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="schedule-enabled"
name="enabled">
<label class="form-check-label" for="schedule-enabled">
Schedule enabled
</label>
</div>
</div>
<!-- Metadata -->
<div class="row">
<div class="col-md-6">
<small class="text-muted">
<strong>Created:</strong> <span id="created-at">-</span>
</small>
</div>
<div class="col-md-6">
<small class="text-muted">
<strong>Last Modified:</strong> <span id="updated-at">-</span>
</small>
</div>
</div>
</div>
</div>
<!-- Cron Expression Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Schedule Configuration</h5>
</div>
<div class="card-body">
<!-- Quick Templates -->
<div class="mb-3">
<label class="form-label">Quick Templates:</label>
<div class="btn-group-vertical btn-group-sm w-100" role="group">
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * *')">
<strong>Daily at Midnight (local)</strong> <code class="float-end">0 0 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 2 * * *')">
<strong>Daily at 2 AM (local)</strong> <code class="float-end">0 2 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 */6 * * *')">
<strong>Every 6 Hours</strong> <code class="float-end">0 */6 * * *</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 * * 0')">
<strong>Weekly (Sunday at Midnight)</strong> <code class="float-end">0 0 * * 0</code>
</button>
<button type="button" class="btn btn-outline-secondary text-start" onclick="setCron('0 0 1 * *')">
<strong>Monthly (1st at Midnight)</strong> <code class="float-end">0 0 1 * *</code>
</button>
</div>
</div>
<!-- Manual Cron Entry -->
<div class="mb-3">
<label for="cron-expression" class="form-label">
Cron Expression <span class="text-danger">*</span>
</label>
<input type="text" class="form-control font-monospace" id="cron-expression"
name="cron_expression" placeholder="0 2 * * *"
oninput="validateCron()" required>
<small class="form-text text-muted">
Format: <code>minute hour day month weekday</code> (local timezone)
</small>
</div>
<!-- Cron Validation Feedback -->
<div id="cron-feedback" class="alert" style="display: none;"></div>
<!-- Run Times Info -->
<div class="row">
<div class="col-md-6">
<div class="alert alert-info">
<strong>Last Run:</strong><br>
<span id="last-run" style="white-space: pre-line;">Never</span>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-info">
<strong>Next Run:</strong><br>
<span id="next-run" style="white-space: pre-line;">Not scheduled</span>
</div>
</div>
</div>
</div>
</div>
<!-- Execution History Card -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Execution History</h5>
</div>
<div class="card-body">
<div id="history-loading" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
<span class="ms-2 text-muted">Loading history...</span>
</div>
<div id="history-content" style="display: none;">
<p class="text-muted">Last 10 scans triggered by this schedule:</p>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Scan ID</th>
<th>Started</th>
<th>Status</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="history-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="history-empty" style="display: none;" class="text-center py-3 text-muted">
No executions yet
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<button type="button" class="btn btn-danger" onclick="deleteSchedule()">
<i class="bi bi-trash"></i> Delete Schedule
</button>
<button type="button" class="btn btn-secondary" onclick="testRun()">
<i class="bi bi-play-fill"></i> Test Run Now
</button>
</div>
<div>
<a href="{{ url_for('main.schedules') }}" class="btn btn-secondary me-2">Cancel</a>
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-check-circle"></i> Save Changes
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- Help Sidebar -->
<div class="col-lg-4">
<div class="card sticky-top" style="top: 20px;">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Cron Expression Help</h5>
</div>
<div class="card-body">
<h6>Field Format:</h6>
<table class="table table-sm">
<thead>
<tr>
<th>Field</th>
<th>Values</th>
</tr>
</thead>
<tbody>
<tr>
<td>Minute</td>
<td>0-59</td>
</tr>
<tr>
<td>Hour</td>
<td>0-23</td>
</tr>
<tr>
<td>Day</td>
<td>1-31</td>
</tr>
<tr>
<td>Month</td>
<td>1-12</td>
</tr>
<tr>
<td>Weekday</td>
<td>0-6 (0=Sunday)</td>
</tr>
</tbody>
</table>
<h6 class="mt-3">Special Characters:</h6>
<ul class="list-unstyled">
<li><code>*</code> - Any value</li>
<li><code>*/n</code> - Every n units</li>
<li><code>1,2,3</code> - Specific values</li>
<li><code>1-5</code> - Range of values</li>
</ul>
<div class="alert alert-info mt-3">
<strong><i class="bi bi-info-circle"></i> Timezone Information:</strong><br>
All cron expressions use your <strong>local system time</strong>.<br><br>
<strong>Current local time:</strong> <span id="current-local"></span><br>
<strong>Your timezone:</strong> <span id="tz-offset"></span>
</div>
</div>
</div>
</div>
</div>
<script>
let scheduleData = null;
// Get schedule ID from URL
const scheduleId = parseInt(window.location.pathname.split('/')[2]);
// Load schedule data
async function loadSchedule() {
try {
const response = await fetch(`/api/schedules/${scheduleId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
scheduleData = await response.json();
// Populate form
populateForm(scheduleData);
// Load execution history
loadHistory();
// Hide loading, show form
document.getElementById('loading').style.display = 'none';
document.getElementById('edit-schedule-form').style.display = 'block';
} catch (error) {
console.error('Error loading schedule:', error);
document.getElementById('loading').style.display = 'none';
document.getElementById('error-state').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Populate form with schedule data
function populateForm(schedule) {
document.getElementById('schedule-id').value = schedule.id;
document.getElementById('schedule-name').value = schedule.name;
document.getElementById('config-file').value = schedule.config_file;
document.getElementById('cron-expression').value = schedule.cron_expression;
document.getElementById('schedule-enabled').checked = schedule.enabled;
// Metadata
document.getElementById('created-at').textContent = new Date(schedule.created_at).toLocaleString();
document.getElementById('updated-at').textContent = new Date(schedule.updated_at).toLocaleString();
// Run times - show in local time
document.getElementById('last-run').textContent = schedule.last_run
? formatRelativeTime(schedule.last_run) + '\n' +
new Date(schedule.last_run).toLocaleString()
: 'Never';
document.getElementById('next-run').textContent = schedule.next_run && schedule.enabled
? formatRelativeTime(schedule.next_run) + '\n' +
new Date(schedule.next_run).toLocaleString()
: (schedule.enabled ? 'Calculating...' : 'Disabled');
// Validate cron
validateCron();
}
// Load execution history
async function loadHistory() {
try {
// Note: This would ideally be a separate API endpoint
// For now, we'll fetch scans filtered by schedule_id
const response = await fetch(`/api/scans?schedule_id=${scheduleId}&limit=10`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const scans = data.scans || [];
renderHistory(scans);
document.getElementById('history-loading').style.display = 'none';
document.getElementById('history-content').style.display = 'block';
} catch (error) {
console.error('Error loading history:', error);
document.getElementById('history-loading').innerHTML = '<p class="text-danger">Failed to load history</p>';
}
}
// Render history table
function renderHistory(scans) {
const tbody = document.getElementById('history-tbody');
tbody.innerHTML = '';
if (scans.length === 0) {
document.querySelector('#history-content .table-responsive').style.display = 'none';
document.getElementById('history-empty').style.display = 'block';
return;
}
document.querySelector('#history-content .table-responsive').style.display = 'block';
document.getElementById('history-empty').style.display = 'none';
scans.forEach(scan => {
const row = document.createElement('tr');
row.classList.add('schedule-row');
row.style.cursor = 'pointer';
row.onclick = () => window.location.href = `/scans/${scan.id}`;
const duration = scan.end_time
? Math.round((new Date(scan.end_time) - new Date(scan.timestamp)) / 1000) + 's'
: '-';
row.innerHTML = `
<td class="mono"><a href="/scans/${scan.id}">#${scan.id}</a></td>
<td>${new Date(scan.timestamp).toLocaleString()}</td>
<td>${getStatusBadge(scan.status)}</td>
<td>${duration}</td>
`;
tbody.appendChild(row);
});
}
// Get status badge
function getStatusBadge(status) {
const badges = {
'running': '<span class="badge bg-primary">Running</span>',
'completed': '<span class="badge bg-success">Completed</span>',
'failed': '<span class="badge bg-danger">Failed</span>',
'pending': '<span class="badge bg-warning">Pending</span>'
};
return badges[status] || '<span class="badge bg-secondary">' + status + '</span>';
}
// Format relative time
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Never';
const now = new Date();
const date = new Date(timestamp);
const diffMs = date - now;
const diffMinutes = Math.abs(Math.floor(diffMs / 60000));
const diffHours = Math.abs(Math.floor(diffMs / 3600000));
const diffDays = Math.abs(Math.floor(diffMs / 86400000));
if (diffMs < 0) {
// Past time
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
if (diffDays === 1) return 'Yesterday';
return `${diffDays} days ago`;
} else {
// Future time
if (diffMinutes < 1) return 'In less than a minute';
if (diffMinutes < 60) return `In ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`;
if (diffHours < 24) return `In ${diffHours} hour${diffHours !== 1 ? 's' : ''}`;
if (diffDays === 1) return 'Tomorrow';
return `In ${diffDays} days`;
}
}
// Set cron from template
function setCron(expression) {
document.getElementById('cron-expression').value = expression;
validateCron();
}
// Validate cron (basic client-side)
function validateCron() {
const expression = document.getElementById('cron-expression').value.trim();
const feedback = document.getElementById('cron-feedback');
if (!expression) {
feedback.style.display = 'none';
return;
}
const parts = expression.split(/\s+/);
if (parts.length !== 5) {
feedback.className = 'alert alert-danger';
feedback.textContent = 'Invalid: Must have exactly 5 fields';
feedback.style.display = 'block';
return;
}
feedback.className = 'alert alert-success';
feedback.textContent = 'Valid cron expression';
feedback.style.display = 'block';
}
// Handle form submission
document.getElementById('edit-schedule-form').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submit-btn');
const originalText = submitBtn.innerHTML;
const formData = {
name: document.getElementById('schedule-name').value.trim(),
cron_expression: document.getElementById('cron-expression').value.trim(),
enabled: document.getElementById('schedule-enabled').checked
};
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving...';
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
showNotification('Schedule updated successfully! Redirecting...', 'success');
setTimeout(() => {
window.location.href = '/schedules';
}, 1500);
} catch (error) {
console.error('Error updating schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// Test run
async function testRun() {
if (!confirm('Trigger a test run of this schedule now?')) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
setTimeout(() => {
window.location.href = `/scans/${data.scan_id}`;
}, 1500);
} catch (error) {
console.error('Error triggering schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Delete schedule
async function deleteSchedule() {
const scheduleName = document.getElementById('schedule-name').value;
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
showNotification('Schedule deleted successfully! Redirecting...', 'success');
setTimeout(() => {
window.location.href = '/schedules';
}, 1500);
} catch (error) {
console.error('Error deleting schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
}
// Update current time display
function updateCurrentTime() {
const now = new Date();
if (document.getElementById('current-local')) {
document.getElementById('current-local').textContent = now.toLocaleTimeString();
}
if (document.getElementById('tz-offset')) {
const offset = -now.getTimezoneOffset() / 60;
document.getElementById('tz-offset').textContent = `CST (UTC${offset >= 0 ? '+' : ''}${offset})`;
}
}
// Load on page load
document.addEventListener('DOMContentLoaded', () => {
loadSchedule();
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,393 @@
{% extends "base.html" %}
{% block title %}Scheduled Scans - SneakyScanner{% endblock %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Scheduled Scans</h1>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="row mb-4">
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="total-schedules">-</div>
<div class="stat-label">Total Schedules</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="enabled-schedules">-</div>
<div class="stat-label">Enabled</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="next-run-time">-</div>
<div class="stat-label">Next Run</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value" id="recent-executions">-</div>
<div class="stat-label">Executions (24h)</div>
</div>
</div>
</div>
<!-- Schedules Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">All Schedules</h5>
</div>
<div class="card-body">
<div id="schedules-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading schedules...</p>
</div>
<div id="schedules-error" style="display: none;" class="alert alert-danger">
<strong>Error:</strong> <span id="error-message"></span>
</div>
<div id="schedules-content" style="display: none;">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Schedule (Cron)</th>
<th>Next Run</th>
<th>Last Run</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="schedules-tbody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
<div id="empty-state" style="display: none;" class="text-center py-5">
<i class="bi bi-calendar-x" style="font-size: 3rem; color: #64748b;"></i>
<h5 class="mt-3 text-muted">No schedules configured</h5>
<p class="text-muted">Create your first schedule to automate scans</p>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary mt-2">
<i class="bi bi-plus-circle"></i> Create Schedule
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Global variables
let schedulesData = [];
// Format relative time (e.g., "in 2 hours", "5 minutes ago")
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Never';
const now = new Date();
const date = new Date(timestamp);
const diffMs = date - now;
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
// Get local time string for tooltip/fallback
const localStr = date.toLocaleString();
if (diffMs < 0) {
// Past time
const absDiffMinutes = Math.abs(diffMinutes);
const absDiffHours = Math.abs(diffHours);
const absDiffDays = Math.abs(diffDays);
if (absDiffMinutes < 1) return 'Just now';
if (absDiffMinutes === 1) return '1 minute ago';
if (absDiffMinutes < 60) return `${absDiffMinutes} minutes ago`;
if (absDiffHours === 1) return '1 hour ago';
if (absDiffHours < 24) return `${absDiffHours} hours ago`;
if (absDiffDays === 1) return 'Yesterday';
if (absDiffDays < 7) return `${absDiffDays} days ago`;
return `<span title="${localStr}">${absDiffDays} days ago</span>`;
} else {
// Future time
if (diffMinutes < 1) return 'In less than a minute';
if (diffMinutes === 1) return 'In 1 minute';
if (diffMinutes < 60) return `In ${diffMinutes} minutes`;
if (diffHours === 1) return 'In 1 hour';
if (diffHours < 24) return `In ${diffHours} hours`;
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`;
return `<span title="${localStr}">In ${diffDays} days</span>`;
}
}
// Get status badge HTML
function getStatusBadge(enabled) {
if (enabled) {
return '<span class="badge bg-success">Enabled</span>';
} else {
return '<span class="badge bg-secondary">Disabled</span>';
}
}
// Load schedules from API
async function loadSchedules() {
try {
const response = await fetch('/api/schedules');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
schedulesData = data.schedules || [];
renderSchedules();
updateStats(data);
// Hide loading, show content
document.getElementById('schedules-loading').style.display = 'none';
document.getElementById('schedules-error').style.display = 'none';
document.getElementById('schedules-content').style.display = 'block';
} catch (error) {
console.error('Error loading schedules:', error);
document.getElementById('schedules-loading').style.display = 'none';
document.getElementById('schedules-content').style.display = 'none';
document.getElementById('schedules-error').style.display = 'block';
document.getElementById('error-message').textContent = error.message;
}
}
// Render schedules table
function renderSchedules() {
const tbody = document.getElementById('schedules-tbody');
tbody.innerHTML = '';
if (schedulesData.length === 0) {
document.querySelector('.table-responsive').style.display = 'none';
document.getElementById('empty-state').style.display = 'block';
return;
}
document.querySelector('.table-responsive').style.display = 'block';
document.getElementById('empty-state').style.display = 'none';
schedulesData.forEach(schedule => {
const row = document.createElement('tr');
row.classList.add('schedule-row');
row.innerHTML = `
<td class="mono">#${schedule.id}</td>
<td>
<strong>${escapeHtml(schedule.name)}</strong>
<br>
<small class="text-muted">${escapeHtml(schedule.config_file)}</small>
</td>
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
<td>${formatRelativeTime(schedule.next_run)}</td>
<td>${formatRelativeTime(schedule.last_run)}</td>
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox"
id="enable-${schedule.id}"
${schedule.enabled ? 'checked' : ''}
onchange="toggleSchedule(${schedule.id}, this.checked)">
<label class="form-check-label" for="enable-${schedule.id}">
${schedule.enabled ? 'Enabled' : 'Disabled'}
</label>
</div>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-secondary" onclick="triggerSchedule(${schedule.id})"
title="Run Now">
<i class="bi bi-play-fill"></i>
</button>
<a href="/schedules/${schedule.id}/edit" class="btn btn-secondary"
title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button class="btn btn-danger" onclick="deleteSchedule(${schedule.id})"
title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
// Update stats
function updateStats(data) {
const totalSchedules = data.total || schedulesData.length;
const enabledSchedules = schedulesData.filter(s => s.enabled).length;
// Find next run time
let nextRun = null;
schedulesData.filter(s => s.enabled && s.next_run).forEach(s => {
const scheduleNext = new Date(s.next_run);
if (!nextRun || scheduleNext < nextRun) {
nextRun = scheduleNext;
}
});
// Calculate executions in last 24h (would need API support)
const recentExecutions = data.recent_executions || 0;
document.getElementById('total-schedules').textContent = totalSchedules;
document.getElementById('enabled-schedules').textContent = enabledSchedules;
document.getElementById('next-run-time').innerHTML = nextRun
? `<small>${formatRelativeTime(nextRun)}</small>`
: '<small>None</small>';
document.getElementById('recent-executions').textContent = recentExecutions;
}
// Toggle schedule enabled/disabled
async function toggleSchedule(scheduleId, enabled) {
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled: enabled })
});
if (!response.ok) {
throw new Error(`Failed to update schedule: ${response.statusText}`);
}
// Reload schedules
await loadSchedules();
// Show success notification
showNotification(`Schedule ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
} catch (error) {
console.error('Error toggling schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
// Revert checkbox
document.getElementById(`enable-${scheduleId}`).checked = !enabled;
}
}
// Manually trigger schedule
async function triggerSchedule(scheduleId) {
if (!confirm('Run this schedule now?')) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}/trigger`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`Failed to trigger schedule: ${response.statusText}`);
}
const data = await response.json();
showNotification(`Scan triggered! Redirecting to scan #${data.scan_id}...`, 'success');
// Redirect to scan detail page
setTimeout(() => {
window.location.href = `/scans/${data.scan_id}`;
}, 1500);
} catch (error) {
console.error('Error triggering schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Delete schedule
async function deleteSchedule(scheduleId) {
const schedule = schedulesData.find(s => s.id === scheduleId);
const scheduleName = schedule ? schedule.name : `#${scheduleId}`;
if (!confirm(`Delete schedule "${scheduleName}"?\n\nThis action cannot be undone. Associated scan history will be preserved.`)) {
return;
}
try {
const response = await fetch(`/api/schedules/${scheduleId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete schedule: ${response.statusText}`);
}
showNotification('Schedule deleted successfully', 'success');
// Reload schedules
await loadSchedules();
} catch (error) {
console.error('Error deleting schedule:', error);
showNotification(`Error: ${error.message}`, 'danger');
}
}
// Show notification
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
notification.remove();
}, 5000);
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load schedules on page load
document.addEventListener('DOMContentLoaded', () => {
loadSchedules();
// Refresh every 30 seconds
setInterval(loadSchedules, 30000);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup - SneakyScanner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
.setup-container {
width: 100%;
max-width: 500px;
padding: 2rem;
}
.card {
border: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.brand-title {
color: #00d9ff;
font-weight: 600;
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="setup-container">
<div class="card">
<div class="card-body p-5">
<div class="text-center mb-4">
<h1 class="brand-title">SneakyScanner</h1>
<p class="text-muted">Initial Setup</p>
</div>
<div class="alert alert-info mb-4">
<strong>Welcome!</strong> Please set an application password to secure your scanner.
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('auth.setup') }}">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password"
class="form-control"
id="password"
name="password"
required
minlength="8"
autofocus
placeholder="Enter password (min 8 characters)">
<div class="form-text">Password must be at least 8 characters long.</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label">Confirm Password</label>
<input type="password"
class="form-control"
id="confirm_password"
name="confirm_password"
required
minlength="8"
placeholder="Confirm your password">
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
Set Password
</button>
</form>
</div>
</div>
<div class="text-center mt-3">
<small class="text-muted">SneakyScanner v1.0 - Phase 2</small>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

158
app/web/utils/pagination.py Normal file
View File

@@ -0,0 +1,158 @@
"""
Pagination utilities for SneakyScanner web application.
Provides helper functions for paginating SQLAlchemy queries.
"""
from typing import Any, Dict, List
from sqlalchemy.orm import Query
class PaginatedResult:
"""Container for paginated query results."""
def __init__(self, items: List[Any], total: int, page: int, per_page: int):
"""
Initialize paginated result.
Args:
items: List of items for current page
total: Total number of items across all pages
page: Current page number (1-indexed)
per_page: Number of items per page
"""
self.items = items
self.total = total
self.page = page
self.per_page = per_page
@property
def pages(self) -> int:
"""Calculate total number of pages."""
if self.per_page == 0:
return 0
return (self.total + self.per_page - 1) // self.per_page
@property
def has_prev(self) -> bool:
"""Check if there is a previous page."""
return self.page > 1
@property
def has_next(self) -> bool:
"""Check if there is a next page."""
return self.page < self.pages
@property
def prev_page(self) -> int:
"""Get previous page number."""
return self.page - 1 if self.has_prev else None
@property
def next_page(self) -> int:
"""Get next page number."""
return self.page + 1 if self.has_next else None
def to_dict(self) -> Dict[str, Any]:
"""
Convert to dictionary for API responses.
Returns:
Dictionary with pagination metadata and items
"""
return {
'items': self.items,
'total': self.total,
'page': self.page,
'per_page': self.per_page,
'pages': self.pages,
'has_prev': self.has_prev,
'has_next': self.has_next,
'prev_page': self.prev_page,
'next_page': self.next_page,
}
def paginate(query: Query, page: int = 1, per_page: int = 20,
max_per_page: int = 100) -> PaginatedResult:
"""
Paginate a SQLAlchemy query.
Args:
query: SQLAlchemy query to paginate
page: Page number (1-indexed, default: 1)
per_page: Items per page (default: 20)
max_per_page: Maximum items per page (default: 100)
Returns:
PaginatedResult with items and pagination metadata
Examples:
>>> from web.models import Scan
>>> query = db.query(Scan).order_by(Scan.timestamp.desc())
>>> result = paginate(query, page=1, per_page=20)
>>> scans = result.items
>>> total_pages = result.pages
"""
# Validate and sanitize parameters
page = max(1, page) # Page must be at least 1
per_page = max(1, min(per_page, max_per_page)) # Clamp per_page
# Get total count
total = query.count()
# Calculate offset
offset = (page - 1) * per_page
# Execute query with limit and offset
items = query.limit(per_page).offset(offset).all()
return PaginatedResult(
items=items,
total=total,
page=page,
per_page=per_page
)
def validate_page_params(page: Any, per_page: Any,
max_per_page: int = 100) -> tuple[int, int]:
"""
Validate and sanitize pagination parameters.
Args:
page: Page number (any type, will be converted to int)
per_page: Items per page (any type, will be converted to int)
max_per_page: Maximum items per page (default: 100)
Returns:
Tuple of (validated_page, validated_per_page)
Examples:
>>> validate_page_params('2', '50')
(2, 50)
>>> validate_page_params(-1, 200)
(1, 100)
>>> validate_page_params(None, None)
(1, 20)
"""
# Default values
default_page = 1
default_per_page = 20
# Convert to int, use default if invalid
try:
page = int(page) if page is not None else default_page
except (ValueError, TypeError):
page = default_page
try:
per_page = int(per_page) if per_page is not None else default_per_page
except (ValueError, TypeError):
per_page = default_per_page
# Validate ranges
page = max(1, page)
per_page = max(1, min(per_page, max_per_page))
return page, per_page

323
app/web/utils/settings.py Normal file
View File

@@ -0,0 +1,323 @@
"""
Settings management system for SneakyScanner.
Provides secure storage and retrieval of application settings with encryption
for sensitive values like passwords and API tokens.
"""
import json
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
import bcrypt
from cryptography.fernet import Fernet
from sqlalchemy.orm import Session
from web.models import Setting
class SettingsManager:
"""
Manages application settings with encryption support.
Handles CRUD operations for settings stored in the database, with automatic
encryption/decryption for sensitive values.
"""
# Keys that should be encrypted when stored
ENCRYPTED_KEYS = {
'smtp_password',
'api_token',
'encryption_key',
}
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
"""
Initialize the settings manager.
Args:
db_session: SQLAlchemy database session
encryption_key: Fernet encryption key (32 url-safe base64-encoded bytes)
If not provided, will generate or load from environment
"""
self.db = db_session
self._encryption_key = encryption_key or self._get_or_create_encryption_key()
self._cipher = Fernet(self._encryption_key)
def _get_or_create_encryption_key(self) -> bytes:
"""
Get encryption key from environment or generate new one.
Returns:
Fernet encryption key (32 url-safe base64-encoded bytes)
"""
# Try to get from environment variable
key_str = os.environ.get('SNEAKYSCANNER_ENCRYPTION_KEY')
if key_str:
return key_str.encode()
# Try to get from settings table (for persistence)
existing_key = self.get('encryption_key', decrypt=False)
if existing_key:
return existing_key.encode()
# Generate new key if none exists
new_key = Fernet.generate_key()
# Store it in settings (unencrypted, as it's the key itself)
self._store_raw('encryption_key', new_key.decode())
return new_key
def _store_raw(self, key: str, value: str) -> None:
"""Store a setting without encryption (internal use only)."""
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value
setting.updated_at = datetime.utcnow()
else:
setting = Setting(key=key, value=value)
self.db.add(setting)
self.db.commit()
def _should_encrypt(self, key: str) -> bool:
"""Check if a setting key should be encrypted."""
return key in self.ENCRYPTED_KEYS
def _encrypt(self, value: str) -> str:
"""Encrypt a string value."""
return self._cipher.encrypt(value.encode()).decode()
def _decrypt(self, encrypted_value: str) -> str:
"""Decrypt an encrypted value."""
return self._cipher.decrypt(encrypted_value.encode()).decode()
def get(self, key: str, default: Any = None, decrypt: bool = True) -> Any:
"""
Get a setting value by key.
Args:
key: Setting key to retrieve
default: Default value if key not found
decrypt: Whether to decrypt if value is encrypted
Returns:
Setting value (automatically decrypts if needed and decrypt=True)
"""
setting = self.db.query(Setting).filter_by(key=key).first()
if not setting:
return default
value = setting.value
if value is None:
return default
# Decrypt if needed
if decrypt and self._should_encrypt(key):
try:
value = self._decrypt(value)
except Exception:
# If decryption fails, return as-is (might be legacy unencrypted value)
pass
# Try to parse JSON for complex types
if value.startswith('[') or value.startswith('{'):
try:
return json.loads(value)
except json.JSONDecodeError:
pass
return value
def set(self, key: str, value: Any, encrypt: bool = None) -> None:
"""
Set a setting value.
Args:
key: Setting key
value: Setting value (will be JSON-encoded if dict/list)
encrypt: Force encryption on/off (None = auto-detect from ENCRYPTED_KEYS)
"""
# Convert complex types to JSON
if isinstance(value, (dict, list)):
value_str = json.dumps(value)
else:
value_str = str(value)
# Determine if we should encrypt
should_encrypt = encrypt if encrypt is not None else self._should_encrypt(key)
if should_encrypt:
value_str = self._encrypt(value_str)
# Store in database
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value_str
setting.updated_at = datetime.utcnow()
else:
setting = Setting(key=key, value=value_str)
self.db.add(setting)
self.db.commit()
def delete(self, key: str) -> bool:
"""
Delete a setting.
Args:
key: Setting key to delete
Returns:
True if deleted, False if key not found
"""
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
self.db.delete(setting)
self.db.commit()
return True
return False
def get_all(self, decrypt: bool = False, sanitize: bool = True) -> Dict[str, Any]:
"""
Get all settings as a dictionary.
Args:
decrypt: Whether to decrypt encrypted values
sanitize: If True, replaces encrypted values with '***' for security
Returns:
Dictionary of all settings
"""
settings = self.db.query(Setting).all()
result = {}
for setting in settings:
key = setting.key
value = setting.value
if value is None:
result[key] = None
continue
# Handle sanitization for sensitive keys
if sanitize and self._should_encrypt(key):
result[key] = '***ENCRYPTED***'
continue
# Decrypt if requested
if decrypt and self._should_encrypt(key):
try:
value = self._decrypt(value)
except Exception:
pass
# Try to parse JSON
if value and (value.startswith('[') or value.startswith('{')):
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
result[key] = value
return result
def init_defaults(self) -> None:
"""
Initialize default settings if they don't exist.
This should be called on first app startup to populate default values.
"""
defaults = {
# SMTP settings
'smtp_server': 'localhost',
'smtp_port': 587,
'smtp_username': '',
'smtp_password': '',
'smtp_from_email': 'noreply@sneakyscanner.local',
'smtp_to_emails': [],
# Authentication
'app_password': '', # Will need to be set by user
# Retention policy
'retention_days': 0, # 0 = keep forever
# Alert settings
'cert_expiry_threshold': 30, # Days before expiry to alert
'email_alerts_enabled': False,
}
for key, value in defaults.items():
# Only set if doesn't exist
if self.db.query(Setting).filter_by(key=key).first() is None:
self.set(key, value)
class PasswordManager:
"""
Manages password hashing and verification using bcrypt.
Used for the single-user authentication system.
"""
@staticmethod
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Bcrypt hash string
"""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
@staticmethod
def verify_password(password: str, hashed: str) -> bool:
"""
Verify a password against a bcrypt hash.
Args:
password: Plain text password to verify
hashed: Bcrypt hash to check against
Returns:
True if password matches, False otherwise
"""
try:
return bcrypt.checkpw(password.encode(), hashed.encode())
except Exception:
return False
@staticmethod
def set_app_password(settings_manager: SettingsManager, password: str) -> None:
"""
Set the application password (stored as bcrypt hash).
Args:
settings_manager: SettingsManager instance
password: New password to set
"""
hashed = PasswordManager.hash_password(password)
# Password hash stored as regular setting (not encrypted, as it's already a hash)
settings_manager.set('app_password', hashed, encrypt=False)
@staticmethod
def verify_app_password(settings_manager: SettingsManager, password: str) -> bool:
"""
Verify the application password.
Args:
settings_manager: SettingsManager instance
password: Password to verify
Returns:
True if password matches, False otherwise
"""
stored_hash = settings_manager.get('app_password', decrypt=False)
if not stored_hash:
# No password set - should prompt user to create one
return False
return PasswordManager.verify_password(password, stored_hash)

290
app/web/utils/validators.py Normal file
View File

@@ -0,0 +1,290 @@
"""
Input validation utilities for SneakyScanner web application.
Provides validation functions for API inputs, file paths, and data integrity.
"""
import os
from pathlib import Path
from typing import Optional
import yaml
def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
"""
Validate that a configuration file exists and is valid YAML.
Args:
file_path: Path to configuration file (absolute or relative filename)
Returns:
Tuple of (is_valid, error_message)
If valid, returns (True, None)
If invalid, returns (False, error_message)
Examples:
>>> validate_config_file('/app/configs/example.yaml')
(True, None)
>>> validate_config_file('example.yaml')
(True, None)
>>> validate_config_file('/nonexistent.yaml')
(False, 'File does not exist: /nonexistent.yaml')
"""
# Check if path is provided
if not file_path:
return False, 'Config file path is required'
# If file_path is just a filename (not absolute), prepend configs directory
if not file_path.startswith('/'):
file_path = f'/app/configs/{file_path}'
# Convert to Path object
path = Path(file_path)
# Check if file exists
if not path.exists():
return False, f'File does not exist: {file_path}'
# Check if it's a file (not directory)
if not path.is_file():
return False, f'Path is not a file: {file_path}'
# Check file extension
if path.suffix.lower() not in ['.yaml', '.yml']:
return False, f'File must be YAML (.yaml or .yml): {file_path}'
# Try to parse as YAML
try:
with open(path, 'r') as f:
config = yaml.safe_load(f)
# Check if it's a dictionary (basic structure validation)
if not isinstance(config, dict):
return False, 'Config file must contain a YAML dictionary'
# Check for required top-level keys
if 'title' not in config:
return False, 'Config file missing required "title" field'
if 'sites' not in config:
return False, 'Config file missing required "sites" field'
# Validate sites structure
if not isinstance(config['sites'], list):
return False, '"sites" must be a list'
if len(config['sites']) == 0:
return False, '"sites" list cannot be empty'
except yaml.YAMLError as e:
return False, f'Invalid YAML syntax: {str(e)}'
except Exception as e:
return False, f'Error reading config file: {str(e)}'
return True, None
def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
"""
Validate scan status value.
Args:
status: Status string to validate
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_scan_status('running')
(True, None)
>>> validate_scan_status('invalid')
(False, 'Invalid status: invalid. Must be one of: running, completed, failed')
"""
valid_statuses = ['running', 'completed', 'failed']
if status not in valid_statuses:
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'
return True, None
def validate_triggered_by(triggered_by: str) -> tuple[bool, Optional[str]]:
"""
Validate triggered_by value.
Args:
triggered_by: Source that triggered the scan
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_triggered_by('manual')
(True, None)
>>> validate_triggered_by('api')
(True, None)
"""
valid_sources = ['manual', 'scheduled', 'api']
if triggered_by not in valid_sources:
return False, f'Invalid triggered_by: {triggered_by}. Must be one of: {", ".join(valid_sources)}'
return True, None
def validate_scan_id(scan_id: any) -> tuple[bool, Optional[str]]:
"""
Validate scan ID is a positive integer.
Args:
scan_id: Scan ID to validate
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_scan_id(42)
(True, None)
>>> validate_scan_id('42')
(True, None)
>>> validate_scan_id(-1)
(False, 'Scan ID must be a positive integer')
"""
try:
scan_id_int = int(scan_id)
if scan_id_int <= 0:
return False, 'Scan ID must be a positive integer'
except (ValueError, TypeError):
return False, f'Invalid scan ID: {scan_id}'
return True, None
def validate_file_path(file_path: str, must_exist: bool = True) -> tuple[bool, Optional[str]]:
"""
Validate a file path.
Args:
file_path: Path to validate
must_exist: If True, file must exist. If False, only validate format.
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_file_path('/app/output/scan.json', must_exist=False)
(True, None)
>>> validate_file_path('', must_exist=False)
(False, 'File path is required')
"""
if not file_path:
return False, 'File path is required'
# Check for path traversal attempts
if '..' in file_path:
return False, 'Path traversal not allowed'
if must_exist:
path = Path(file_path)
if not path.exists():
return False, f'File does not exist: {file_path}'
if not path.is_file():
return False, f'Path is not a file: {file_path}'
return True, None
def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename by removing/replacing unsafe characters.
Args:
filename: Original filename
Returns:
Sanitized filename safe for filesystem
Examples:
>>> sanitize_filename('my scan.json')
'my_scan.json'
>>> sanitize_filename('../../etc/passwd')
'etc_passwd'
"""
# Remove path components
filename = os.path.basename(filename)
# Replace unsafe characters with underscore
unsafe_chars = ['/', '\\', '..', ' ', ':', '*', '?', '"', '<', '>', '|']
for char in unsafe_chars:
filename = filename.replace(char, '_')
# Remove leading/trailing underscores and dots
filename = filename.strip('_.')
# Ensure filename is not empty
if not filename:
filename = 'unnamed'
return filename
def validate_port(port: any) -> tuple[bool, Optional[str]]:
"""
Validate port number.
Args:
port: Port number to validate
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_port(443)
(True, None)
>>> validate_port(70000)
(False, 'Port must be between 1 and 65535')
"""
try:
port_int = int(port)
if port_int < 1 or port_int > 65535:
return False, 'Port must be between 1 and 65535'
except (ValueError, TypeError):
return False, f'Invalid port: {port}'
return True, None
def validate_ip_address(ip: str) -> tuple[bool, Optional[str]]:
"""
Validate IPv4 address format (basic validation).
Args:
ip: IP address string
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_ip_address('192.168.1.1')
(True, None)
>>> validate_ip_address('256.1.1.1')
(False, 'Invalid IP address format')
"""
if not ip:
return False, 'IP address is required'
# Basic IPv4 validation
parts = ip.split('.')
if len(parts) != 4:
return False, 'Invalid IP address format'
try:
for part in parts:
num = int(part)
if num < 0 or num > 255:
return False, 'Invalid IP address format'
except (ValueError, TypeError):
return False, 'Invalid IP address format'
return True, None