restructure of dirs, huge docs update

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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