restructure of dirs, huge docs update
This commit is contained in:
114
app/alembic.ini
Normal file
114
app/alembic.ini
Normal 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
235
app/init_db.py
Executable 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
83
app/migrations/env.py
Normal 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()
|
||||
24
app/migrations/script.py.mako
Normal file
24
app/migrations/script.py.mako
Normal 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"}
|
||||
221
app/migrations/versions/001_initial_schema.py
Normal file
221
app/migrations/versions/001_initial_schema.py
Normal 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')
|
||||
28
app/migrations/versions/002_add_scan_indexes.py
Normal file
28
app/migrations/versions/002_add_scan_indexes.py
Normal 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')
|
||||
39
app/migrations/versions/003_add_scan_timing_fields.py
Normal file
39
app/migrations/versions/003_add_scan_timing_fields.py
Normal 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
34
app/requirements-web.txt
Normal 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
5
app/requirements.txt
Normal 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
327
app/src/report_generator.py
Executable 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
826
app/src/scanner.py
Normal 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())
|
||||
201
app/src/screenshot_capture.py
Normal file
201
app/src/screenshot_capture.py
Normal 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
|
||||
1424
app/templates/report_mockup.html
Normal file
1424
app/templates/report_mockup.html
Normal file
File diff suppressed because it is too large
Load Diff
949
app/templates/report_template.html
Normal file
949
app/templates/report_template.html
Normal 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 (<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 (>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
1
app/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test package for SneakyScanner."""
|
||||
384
app/tests/conftest.py
Normal file
384
app/tests/conftest.py
Normal 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)
|
||||
279
app/tests/test_authentication.py
Normal file
279
app/tests/test_authentication.py
Normal 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 == '/'
|
||||
225
app/tests/test_background_jobs.py
Normal file
225
app/tests/test_background_jobs.py
Normal 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
|
||||
483
app/tests/test_config_api.py
Normal file
483
app/tests/test_config_api.py
Normal 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)
|
||||
545
app/tests/test_config_service.py
Normal file
545
app/tests/test_config_service.py
Normal 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'
|
||||
267
app/tests/test_error_handling.py
Normal file
267
app/tests/test_error_handling.py
Normal 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
267
app/tests/test_scan_api.py
Normal 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
|
||||
319
app/tests/test_scan_comparison.py
Normal file
319
app/tests/test_scan_comparison.py
Normal 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
|
||||
402
app/tests/test_scan_service.py
Normal file
402
app/tests/test_scan_service.py
Normal 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
|
||||
639
app/tests/test_schedule_api.py
Normal file
639
app/tests/test_schedule_api.py
Normal 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"
|
||||
671
app/tests/test_schedule_service.py
Normal file
671
app/tests/test_schedule_service.py
Normal 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
325
app/tests/test_stats_api.py
Normal 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
197
app/validate_phase1.py
Executable 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
0
app/web/__init__.py
Normal file
0
app/web/api/__init__.py
Normal file
0
app/web/api/__init__.py
Normal file
144
app/web/api/alerts.py
Normal file
144
app/web/api/alerts.py
Normal 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
452
app/web/api/configs.py
Normal 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
338
app/web/api/scans.py
Normal 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
331
app/web/api/schedules.py
Normal 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
271
app/web/api/settings.py
Normal 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
258
app/web/api/stats.py
Normal 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
599
app/web/app.py
Normal 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
9
app/web/auth/__init__.py
Normal 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']
|
||||
65
app/web/auth/decorators.py
Normal file
65
app/web/auth/decorators.py
Normal 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
107
app/web/auth/models.py
Normal 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
120
app/web/auth/routes.py
Normal 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
6
app/web/jobs/__init__.py
Normal 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
158
app/web/jobs/scan_job.py
Normal 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
348
app/web/models.py
Normal 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}...')>"
|
||||
5
app/web/routes/__init__.py
Normal file
5
app/web/routes/__init__.py
Normal 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
221
app/web/routes/main.py
Normal 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'))
|
||||
10
app/web/services/__init__.py
Normal file
10
app/web/services/__init__.py
Normal 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']
|
||||
552
app/web/services/config_service.py
Normal file
552
app/web/services/config_service.py
Normal 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)
|
||||
1013
app/web/services/scan_service.py
Normal file
1013
app/web/services/scan_service.py
Normal file
File diff suppressed because it is too large
Load Diff
483
app/web/services/schedule_service.py
Normal file
483
app/web/services/schedule_service.py
Normal 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"
|
||||
356
app/web/services/scheduler_service.py
Normal file
356
app/web/services/scheduler_service.py
Normal 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
|
||||
]
|
||||
507
app/web/static/css/config-manager.css
Normal file
507
app/web/static/css/config-manager.css
Normal 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;
|
||||
}
|
||||
334
app/web/static/css/styles.css
Normal file
334
app/web/static/css/styles.css
Normal 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;
|
||||
}
|
||||
633
app/web/static/js/config-manager.js
Normal file
633
app/web/static/js/config-manager.js
Normal 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');
|
||||
}
|
||||
}
|
||||
95
app/web/templates/base.html
Normal file
95
app/web/templates/base.html
Normal 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>
|
||||
263
app/web/templates/config_edit.html
Normal file
263
app/web/templates/config_edit.html
Normal 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 %}
|
||||
415
app/web/templates/config_upload.html
Normal file
415
app/web/templates/config_upload.html
Normal 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 %}
|
||||
377
app/web/templates/configs.html
Normal file
377
app/web/templates/configs.html
Normal 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 %}
|
||||
580
app/web/templates/dashboard.html
Normal file
580
app/web/templates/dashboard.html
Normal 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 %}
|
||||
84
app/web/templates/errors/400.html
Normal file
84
app/web/templates/errors/400.html
Normal 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>
|
||||
84
app/web/templates/errors/401.html
Normal file
84
app/web/templates/errors/401.html
Normal 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>
|
||||
84
app/web/templates/errors/403.html
Normal file
84
app/web/templates/errors/403.html
Normal 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>
|
||||
84
app/web/templates/errors/404.html
Normal file
84
app/web/templates/errors/404.html
Normal 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>
|
||||
84
app/web/templates/errors/405.html
Normal file
84
app/web/templates/errors/405.html
Normal 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>
|
||||
114
app/web/templates/errors/500.html
Normal file
114
app/web/templates/errors/500.html
Normal 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>
|
||||
48
app/web/templates/login.html
Normal file
48
app/web/templates/login.html
Normal 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 %}
|
||||
545
app/web/templates/scan_compare.html
Normal file
545
app/web/templates/scan_compare.html
Normal 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 %}
|
||||
605
app/web/templates/scan_detail.html
Normal file
605
app/web/templates/scan_detail.html
Normal 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 %}
|
||||
487
app/web/templates/scans.html
Normal file
487
app/web/templates/scans.html
Normal 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 %}
|
||||
442
app/web/templates/schedule_create.html
Normal file
442
app/web/templates/schedule_create.html
Normal 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 %}
|
||||
596
app/web/templates/schedule_edit.html
Normal file
596
app/web/templates/schedule_edit.html
Normal 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 %}
|
||||
393
app/web/templates/schedules.html
Normal file
393
app/web/templates/schedules.html
Normal 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 %}
|
||||
95
app/web/templates/setup.html
Normal file
95
app/web/templates/setup.html
Normal 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>
|
||||
0
app/web/utils/__init__.py
Normal file
0
app/web/utils/__init__.py
Normal file
158
app/web/utils/pagination.py
Normal file
158
app/web/utils/pagination.py
Normal 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
323
app/web/utils/settings.py
Normal 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
290
app/web/utils/validators.py
Normal 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
|
||||
Reference in New Issue
Block a user