nightly #3

Merged
ptarrant merged 2 commits from nightly into beta 2025-11-20 03:46:49 +00:00
22 changed files with 1036 additions and 3438 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ class TestScanAPIEndpoints:
assert len(data['scans']) == 1 assert len(data['scans']) == 1
assert data['scans'][0]['id'] == sample_scan.id assert data['scans'][0]['id'] == sample_scan.id
def test_list_scans_pagination(self, client, db): def test_list_scans_pagination(self, client, db, sample_db_config):
"""Test scan list pagination.""" """Test scan list pagination."""
# Create 25 scans # Create 25 scans
for i in range(25): for i in range(25):
@@ -126,7 +126,7 @@ class TestScanAPIEndpoints:
def test_trigger_scan_success(self, client, db, sample_db_config): def test_trigger_scan_success(self, client, db, sample_db_config):
"""Test triggering a new scan.""" """Test triggering a new scan."""
response = client.post('/api/scans', response = client.post('/api/scans',
json={'config_file': str(sample_db_config)}, json={'config_id': sample_db_config.id},
content_type='application/json' content_type='application/json'
) )
assert response.status_code == 201 assert response.status_code == 201
@@ -142,8 +142,8 @@ class TestScanAPIEndpoints:
assert scan.status == 'running' assert scan.status == 'running'
assert scan.triggered_by == 'api' assert scan.triggered_by == 'api'
def test_trigger_scan_missing_config_file(self, client, db): def test_trigger_scan_missing_config_id(self, client, db):
"""Test triggering scan without config_file.""" """Test triggering scan without config_id."""
response = client.post('/api/scans', response = client.post('/api/scans',
json={}, json={},
content_type='application/json' content_type='application/json'
@@ -152,12 +152,12 @@ class TestScanAPIEndpoints:
data = json.loads(response.data) data = json.loads(response.data)
assert 'error' in data assert 'error' in data
assert 'config_file is required' in data['message'] assert 'config_id is required' in data['message']
def test_trigger_scan_invalid_config_file(self, client, db): def test_trigger_scan_invalid_config_id(self, client, db):
"""Test triggering scan with non-existent config file.""" """Test triggering scan with non-existent config."""
response = client.post('/api/scans', response = client.post('/api/scans',
json={'config_file': '/nonexistent/config.yaml'}, json={'config_id': 99999},
content_type='application/json' content_type='application/json'
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -231,7 +231,7 @@ class TestScanAPIEndpoints:
""" """
# Step 1: Trigger scan # Step 1: Trigger scan
response = client.post('/api/scans', response = client.post('/api/scans',
json={'config_file': str(sample_db_config)}, json={'config_id': sample_db_config.id},
content_type='application/json' content_type='application/json'
) )
assert response.status_code == 201 assert response.status_code == 201

View File

@@ -151,7 +151,7 @@ class TestScheduleAPIEndpoints:
data = json.loads(response.data) data = json.loads(response.data)
assert data['id'] == sample_schedule.id assert data['id'] == sample_schedule.id
assert data['name'] == sample_schedule.name assert data['name'] == sample_schedule.name
assert data['config_file'] == sample_schedule.config_file assert data['config_id'] == sample_schedule.config_id
assert data['cron_expression'] == sample_schedule.cron_expression assert data['cron_expression'] == sample_schedule.cron_expression
assert data['enabled'] == sample_schedule.enabled assert data['enabled'] == sample_schedule.enabled
assert 'history' in data assert 'history' in data
@@ -169,7 +169,7 @@ class TestScheduleAPIEndpoints:
"""Test creating a new schedule.""" """Test creating a new schedule."""
schedule_data = { schedule_data = {
'name': 'New Test Schedule', 'name': 'New Test Schedule',
'config_file': sample_db_config, 'config_id': sample_db_config.id,
'cron_expression': '0 3 * * *', 'cron_expression': '0 3 * * *',
'enabled': True 'enabled': True
} }
@@ -197,7 +197,7 @@ class TestScheduleAPIEndpoints:
# Missing cron_expression # Missing cron_expression
schedule_data = { schedule_data = {
'name': 'Incomplete Schedule', 'name': 'Incomplete Schedule',
'config_file': '/app/configs/test.yaml' 'config_id': 1
} }
response = client.post( response = client.post(
@@ -215,7 +215,7 @@ class TestScheduleAPIEndpoints:
"""Test creating schedule with invalid cron expression.""" """Test creating schedule with invalid cron expression."""
schedule_data = { schedule_data = {
'name': 'Invalid Cron Schedule', 'name': 'Invalid Cron Schedule',
'config_file': sample_db_config, 'config_id': sample_db_config.id,
'cron_expression': 'invalid cron' 'cron_expression': 'invalid cron'
} }
@@ -231,10 +231,10 @@ class TestScheduleAPIEndpoints:
assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower() assert 'invalid' in data['error'].lower() or 'cron' in data['error'].lower()
def test_create_schedule_invalid_config(self, client, db): def test_create_schedule_invalid_config(self, client, db):
"""Test creating schedule with non-existent config file.""" """Test creating schedule with non-existent config."""
schedule_data = { schedule_data = {
'name': 'Invalid Config Schedule', 'name': 'Invalid Config Schedule',
'config_file': '/nonexistent/config.yaml', 'config_id': 99999,
'cron_expression': '0 2 * * *' 'cron_expression': '0 2 * * *'
} }
@@ -399,7 +399,7 @@ class TestScheduleAPIEndpoints:
assert scan is not None assert scan is not None
assert scan.triggered_by == 'manual' assert scan.triggered_by == 'manual'
assert scan.schedule_id == sample_schedule.id assert scan.schedule_id == sample_schedule.id
assert scan.config_file == sample_schedule.config_file assert scan.config_id == sample_schedule.config_id
def test_trigger_schedule_not_found(self, client, db): def test_trigger_schedule_not_found(self, client, db):
"""Test triggering non-existent schedule.""" """Test triggering non-existent schedule."""
@@ -436,7 +436,7 @@ class TestScheduleAPIEndpoints:
# 1. Create schedule # 1. Create schedule
schedule_data = { schedule_data = {
'name': 'Integration Test Schedule', 'name': 'Integration Test Schedule',
'config_file': sample_db_config, 'config_id': sample_db_config.id,
'cron_expression': '0 2 * * *', 'cron_expression': '0 2 * * *',
'enabled': True 'enabled': True
} }
@@ -527,7 +527,7 @@ class TestScheduleAPIEndpoints:
"""Test creating a disabled schedule.""" """Test creating a disabled schedule."""
schedule_data = { schedule_data = {
'name': 'Disabled Schedule', 'name': 'Disabled Schedule',
'config_file': sample_db_config, 'config_id': sample_db_config.id,
'cron_expression': '0 2 * * *', 'cron_expression': '0 2 * * *',
'enabled': False 'enabled': False
} }
@@ -600,7 +600,7 @@ class TestScheduleAPICronValidation:
for cron_expr in valid_expressions: for cron_expr in valid_expressions:
schedule_data = { schedule_data = {
'name': f'Schedule for {cron_expr}', 'name': f'Schedule for {cron_expr}',
'config_file': sample_db_config, 'config_id': sample_db_config.id,
'cron_expression': cron_expr 'cron_expression': cron_expr
} }
@@ -626,7 +626,7 @@ class TestScheduleAPICronValidation:
for cron_expr in invalid_expressions: for cron_expr in invalid_expressions:
schedule_data = { schedule_data = {
'name': f'Schedule for {cron_expr}', 'name': f'Schedule for {cron_expr}',
'config_file': sample_db_config, 'config_id': sample_db_config.id,
'cron_expression': cron_expr 'cron_expression': cron_expr
} }

View File

@@ -128,8 +128,6 @@ def edit_schedule(schedule_id):
Returns: Returns:
Rendered schedule edit template Rendered schedule edit template
""" """
from flask import flash
# Note: Schedule data is loaded via AJAX in the template # Note: Schedule data is loaded via AJAX in the template
# This just renders the page with the schedule_id in the URL # This just renders the page with the schedule_id in the URL
return render_template('schedule_edit.html', schedule_id=schedule_id) return render_template('schedule_edit.html', schedule_id=schedule_id)
@@ -159,51 +157,6 @@ def configs():
return render_template('configs.html') 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'))
@bp.route('/alerts') @bp.route('/alerts')
@login_required @login_required
def alerts(): def alerts():

View File

@@ -6,13 +6,8 @@ both database-stored (primary) and file-based (deprecated).
""" """
import os import os
import re from typing import Dict, List, Any, Optional
import yaml
import ipaddress
from typing import Dict, List, Tuple, Any, Optional
from datetime import datetime from datetime import datetime
from pathlib import Path
from werkzeug.utils import secure_filename
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -342,626 +337,3 @@ class ConfigService:
self.db.commit() self.db.commit()
return self.get_config_by_id(config_id) return self.get_config_by_id(config_id)
# ============================================================================
# Legacy YAML File Operations (Deprecated)
# ============================================================================
def list_configs_file(self) -> List[Dict[str, Any]]:
"""
[DEPRECATED] 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}")
# Create inline sites in database (if any)
self.create_inline_sites(parsed)
# 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_file(self, filename: str, yaml_content: str) -> None:
"""
[DEPRECATED] 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_file(self, filename: str) -> None:
"""
[DEPRECATED] 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)
# Note: This function is deprecated. Schedules now use config_id.
# This code path should not be reached for new configs.
deleted_schedules = []
import logging
logging.getLogger(__name__).warning(
f"delete_config_file called for '{filename}' - this is deprecated. Use database configs with config_id instead."
)
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, check_site_refs: bool = True) -> Tuple[bool, str]:
"""
Validate parsed YAML config structure.
Supports both legacy format (inline IPs) and new format (site references or CIDRs).
Args:
content: Parsed YAML config as dict
check_site_refs: If True, validates that referenced sites exist in database
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"
# Check if this is a site reference (new format)
if 'site_ref' in site:
# Site reference format
site_ref = site.get('site_ref')
if not isinstance(site_ref, str) or not site_ref.strip():
return False, f"Site {i+1} field 'site_ref' must be a non-empty string"
# Validate site reference exists (if check enabled)
if check_site_refs:
try:
from web.services.site_service import SiteService
from flask import current_app
site_service = SiteService(current_app.db_session)
referenced_site = site_service.get_site_by_name(site_ref)
if not referenced_site:
return False, f"Site {i+1}: Referenced site '{site_ref}' does not exist"
except Exception as e:
# If we can't check (e.g., outside app context), skip validation
pass
continue # Site reference is valid
# Check if this is inline site creation with CIDRs (new format)
if 'cidrs' in site:
# Inline site creation with CIDR format
if 'name' not in site:
return False, f"Site {i+1} with inline CIDRs missing required field: 'name'"
cidrs = site.get('cidrs')
if not isinstance(cidrs, list):
return False, f"Site {i+1} field 'cidrs' must be a list"
if len(cidrs) == 0:
return False, f"Site {i+1} must have at least one CIDR"
# Validate each CIDR
for j, cidr_config in enumerate(cidrs):
if not isinstance(cidr_config, dict):
return False, f"Site {i+1} CIDR {j+1} must be a dictionary/object"
if 'cidr' not in cidr_config:
return False, f"Site {i+1} CIDR {j+1} missing required field: 'cidr'"
# Validate CIDR format
cidr_str = cidr_config.get('cidr')
try:
ipaddress.ip_network(cidr_str, strict=False)
except ValueError:
return False, f"Site {i+1} CIDR {j+1}: Invalid CIDR notation '{cidr_str}'"
continue # Inline CIDR site is valid
# Legacy format: inline IPs
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' (or use 'site_ref' or 'cidrs')"
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)
# Note: This function is deprecated. Schedules now use config_id.
# Return empty list as schedules no longer use config_file.
return []
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)
def create_inline_sites(self, config_content: Dict) -> None:
"""
Create sites in the database for inline site definitions in a config.
This method scans the config for inline site definitions (with CIDRs)
and creates them as reusable sites in the database if they don't already exist.
Args:
config_content: Parsed YAML config dictionary
Raises:
ValueError: If site creation fails
"""
try:
from web.services.site_service import SiteService
from flask import current_app
site_service = SiteService(current_app.db_session)
sites = config_content.get('sites', [])
for site_def in sites:
# Skip site references (they already exist)
if 'site_ref' in site_def:
continue
# Skip legacy IP-based sites (not creating those as reusable sites)
if 'ips' in site_def and 'cidrs' not in site_def:
continue
# Process inline CIDR-based sites
if 'cidrs' in site_def:
site_name = site_def.get('name')
# Check if site already exists
existing_site = site_service.get_site_by_name(site_name)
if existing_site:
# Site already exists, skip creation
continue
# Create new site
cidrs = site_def.get('cidrs', [])
description = f"Auto-created from config '{config_content.get('title', 'Unknown')}'"
site_service.create_site(
name=site_name,
description=description,
cidrs=cidrs
)
except Exception as e:
# If site creation fails, log but don't block config creation
import logging
logging.getLogger(__name__).warning(
f"Failed to create inline sites from config: {str(e)}"
)

View File

@@ -13,7 +13,6 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy.exc import IntegrityError
from web.models import ( from web.models import (
Site, SiteIP, ScanSiteAssociation Site, SiteIP, ScanSiteAssociation

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4"> <div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Alert Rules</h1> <h1>Alert Rules</h1>
<div> <div>
<a href="{{ url_for('main.alerts') }}" class="btn btn-outline-primary me-2"> <a href="{{ url_for('main.alerts') }}" class="btn btn-outline-primary me-2">
<i class="bi bi-bell"></i> View Alerts <i class="bi bi-bell"></i> View Alerts
@@ -23,7 +23,7 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h6 class="text-muted mb-2">Total Rules</h6> <h6 class="text-muted mb-2">Total Rules</h6>
<h3 class="mb-0" style="color: #60a5fa;">{{ rules | length }}</h3> <h3 class="mb-0">{{ rules | length }}</h3>
</div> </div>
</div> </div>
</div> </div>
@@ -42,7 +42,7 @@
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Alert Rules Configuration</h5> <h5 class="mb-0">Alert Rules Configuration</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if rules %} {% if rules %}
@@ -121,9 +121,9 @@
onchange="toggleRule({{ rule.id }}, this.checked)"> onchange="toggleRule({{ rule.id }}, this.checked)">
<label class="form-check-label" for="rule-enabled-{{ rule.id }}"> <label class="form-check-label" for="rule-enabled-{{ rule.id }}">
{% if rule.enabled %} {% if rule.enabled %}
<span class="text-success">Active</span> <span class="text-success ms-2">Active</span>
{% else %} {% else %}
<span class="text-muted">Inactive</span> <span class="text-muted ms-2">Inactive</span>
{% endif %} {% endif %}
</label> </label>
</div> </div>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4"> <div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Alert History</h1> <h1>Alert History</h1>
<a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary"> <a href="{{ url_for('main.alert_rules') }}" class="btn btn-primary">
<i class="bi bi-gear"></i> Manage Alert Rules <i class="bi bi-gear"></i> Manage Alert Rules
</a> </a>
@@ -18,7 +18,7 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h6 class="text-muted mb-2">Total Alerts</h6> <h6 class="text-muted mb-2">Total Alerts</h6>
<h3 class="mb-0" style="color: #60a5fa;">{{ pagination.total }}</h3> <h3 class="mb-0">{{ pagination.total }}</h3>
</div> </div>
</div> </div>
</div> </div>
@@ -46,7 +46,7 @@
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h6 class="text-muted mb-2">Unacknowledged</h6> <h6 class="text-muted mb-2">Unacknowledged</h6>
<h3 class="mb-0" style="color: #f97316;"> <h3 class="mb-0 text-warning">
{{ alerts | rejectattr('acknowledged') | list | length }} {{ alerts | rejectattr('acknowledged') | list | length }}
</h3> </h3>
</div> </div>
@@ -104,7 +104,7 @@
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Alerts</h5> <h5 class="mb-0">Alerts</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if alerts %} {% if alerts %}

View File

@@ -45,6 +45,16 @@
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}"
href="{{ url_for('main.dashboard') }}">Dashboard</a> href="{{ url_for('main.dashboard') }}">Dashboard</a>
</li> </li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.endpoint and ('config' in request.endpoint or request.endpoint == 'main.sites') %}active{% endif %}"
href="#" id="configsDropdown" role="button" data-bs-toggle="dropdown">
Configs
</a>
<ul class="dropdown-menu" aria-labelledby="configsDropdown">
<li><a class="dropdown-item" href="{{ url_for('main.configs') }}">Scan Configs</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.sites') }}">Sites</a></li>
</ul>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}" <a class="nav-link {% if request.endpoint == 'main.scans' %}active{% endif %}"
href="{{ url_for('main.scans') }}">Scans</a> href="{{ url_for('main.scans') }}">Scans</a>
@@ -53,14 +63,6 @@
<a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}" <a class="nav-link {% if request.endpoint and 'schedule' in request.endpoint %}active{% endif %}"
href="{{ url_for('main.schedules') }}">Schedules</a> href="{{ url_for('main.schedules') }}">Schedules</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.sites' %}active{% endif %}"
href="{{ url_for('main.sites') }}">Sites</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>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% if request.endpoint and ('alert' in request.endpoint or 'webhook' in request.endpoint) %}active{% endif %}" <a class="nav-link dropdown-toggle {% if request.endpoint and ('alert' in request.endpoint or 'webhook' in request.endpoint) %}active{% endif %}"
href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown"> href="#" id="alertsDropdown" role="button" data-bs-toggle="dropdown">
@@ -105,6 +107,9 @@
</div> </div>
</div> </div>
<!-- Global notification container - always above modals -->
<div id="notification-container" style="position: fixed; top: 20px; right: 20px; z-index: 1100; min-width: 300px;"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>

View File

@@ -1,263 +0,0 @@
{% 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

@@ -1,415 +0,0 @@
{% 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

@@ -6,7 +6,7 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Scan Configurations</h1> <h1>Scan Configurations</h1>
<div> <div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createConfigModal"> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createConfigModal">
<i class="bi bi-plus-circle"></i> Create New Config <i class="bi bi-plus-circle"></i> Create New Config
@@ -44,7 +44,7 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">All Configurations</h5> <h5 class="mb-0">All Configurations</h5>
<input type="text" id="search-input" class="form-control" style="max-width: 300px;" <input type="text" id="search-input" class="form-control" style="max-width: 300px;"
placeholder="Search configs..."> placeholder="Search configs...">
</div> </div>
@@ -93,12 +93,12 @@
<!-- Create Config Modal --> <!-- Create Config Modal -->
<div class="modal fade" id="createConfigModal" tabindex="-1"> <div class="modal fade" id="createConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;"> <div class="modal-content">
<div class="modal-header" style="border-bottom: 1px solid #334155;"> <div class="modal-header">
<h5 class="modal-title" style="color: #60a5fa;"> <h5 class="modal-title">
<i class="bi bi-plus-circle"></i> Create New Configuration <i class="bi bi-plus-circle me-2"></i>Create New Configuration
</h5> </h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="create-config-form"> <form id="create-config-form">
@@ -133,10 +133,10 @@
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer" style="border-top: 1px solid #334155;"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="create-config-btn"> <button type="button" class="btn btn-primary" id="create-config-btn">
<i class="bi bi-check-circle"></i> Create Configuration <i class="bi bi-check-circle me-1"></i>Create Configuration
</button> </button>
</div> </div>
</div> </div>
@@ -146,12 +146,12 @@
<!-- Edit Config Modal --> <!-- Edit Config Modal -->
<div class="modal fade" id="editConfigModal" tabindex="-1"> <div class="modal fade" id="editConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;"> <div class="modal-content">
<div class="modal-header" style="border-bottom: 1px solid #334155;"> <div class="modal-header">
<h5 class="modal-title" style="color: #60a5fa;"> <h5 class="modal-title">
<i class="bi bi-pencil"></i> Edit Configuration <i class="bi bi-pencil me-2"></i>Edit Configuration
</h5> </h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="edit-config-form"> <form id="edit-config-form">
@@ -179,10 +179,10 @@
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer" style="border-top: 1px solid #334155;"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="edit-config-btn"> <button type="button" class="btn btn-primary" id="edit-config-btn">
<i class="bi bi-check-circle"></i> Save Changes <i class="bi bi-check-circle me-1"></i>Save Changes
</button> </button>
</div> </div>
</div> </div>
@@ -192,19 +192,19 @@
<!-- View Config Modal --> <!-- View Config Modal -->
<div class="modal fade" id="viewConfigModal" tabindex="-1"> <div class="modal fade" id="viewConfigModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;"> <div class="modal-content">
<div class="modal-header" style="border-bottom: 1px solid #334155;"> <div class="modal-header">
<h5 class="modal-title" style="color: #60a5fa;"> <h5 class="modal-title">
<i class="bi bi-eye"></i> Configuration Details <i class="bi bi-eye me-2"></i>Configuration Details
</h5> </h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="view-config-content"> <div id="view-config-content">
<!-- Populated by JavaScript --> <!-- Populated by JavaScript -->
</div> </div>
</div> </div>
<div class="modal-footer" style="border-top: 1px solid #334155;"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div> </div>
</div> </div>
@@ -214,22 +214,22 @@
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteConfigModal" tabindex="-1"> <div class="modal fade" id="deleteConfigModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;"> <div class="modal-content">
<div class="modal-header" style="border-bottom: 1px solid #334155;"> <div class="modal-header">
<h5 class="modal-title" style="color: #ef4444;"> <h5 class="modal-title text-danger">
<i class="bi bi-trash"></i> Delete Configuration <i class="bi bi-trash me-2"></i>Delete Configuration
</h5> </h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete configuration <strong id="delete-config-name"></strong>?</p> <p>Are you sure you want to delete configuration <strong id="delete-config-name"></strong>?</p>
<p class="text-warning"><i class="bi bi-exclamation-triangle"></i> This action cannot be undone.</p> <p class="text-warning"><i class="bi bi-exclamation-triangle"></i> This action cannot be undone.</p>
<input type="hidden" id="delete-config-id"> <input type="hidden" id="delete-config-id">
</div> </div>
<div class="modal-footer" style="border-top: 1px solid #334155;"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn"> <button type="button" class="btn btn-danger" id="confirm-delete-btn">
<i class="bi bi-trash"></i> Delete <i class="bi bi-trash me-1"></i>Delete
</button> </button>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
<h1 class="mb-4" style="color: #60a5fa;">Dashboard</h1> <h1 class="mb-4">Dashboard</h1>
</div> </div>
</div> </div>
@@ -42,7 +42,7 @@
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Quick Actions</h5> <h5 class="mb-0">Quick Actions</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<button class="btn btn-primary btn-lg" onclick="showTriggerScanModal()"> <button class="btn btn-primary btn-lg" onclick="showTriggerScanModal()">
@@ -63,7 +63,7 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan Activity (Last 30 Days)</h5> <h5 class="mb-0">Scan Activity (Last 30 Days)</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="chart-loading" class="text-center py-4"> <div id="chart-loading" class="text-center py-4">
@@ -80,7 +80,7 @@
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100"> <div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">Upcoming Schedules</h5> <h5 class="mb-0">Upcoming Schedules</h5>
<a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a> <a href="{{ url_for('main.schedules') }}" class="btn btn-sm btn-secondary">Manage</a>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -105,7 +105,7 @@
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">Recent Scans</h5> <h5 class="mb-0">Recent Scans</h5>
<button class="btn btn-sm btn-secondary" onclick="refreshScans()"> <button class="btn btn-sm btn-secondary" onclick="refreshScans()">
<span id="refresh-text">Refresh</span> <span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span> <span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
@@ -145,9 +145,9 @@
<!-- Trigger Scan Modal --> <!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1"> <div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;"> <div class="modal-content">
<div class="modal-header" style="border-bottom: 1px solid #334155;"> <div class="modal-header">
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5> <h5 class="modal-title">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -172,7 +172,7 @@
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div> <div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form> </form>
</div> </div>
<div class="modal-footer" style="border-top: 1px solid #334155;"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()"> <button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
<span id="modal-trigger-text">Trigger Scan</span> <span id="modal-trigger-text">Trigger Scan</span>

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4"> <div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">All Scans</h1> <h1>All Scans</h1>
<button class="btn btn-primary" onclick="showTriggerScanModal()"> <button class="btn btn-primary" onclick="showTriggerScanModal()">
<span id="trigger-btn-text">Trigger New Scan</span> <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> <span id="trigger-btn-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
@@ -54,7 +54,7 @@
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">Scan History</h5> <h5 class="mb-0">Scan History</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="scans-loading" class="text-center py-5"> <div id="scans-loading" class="text-center py-5">
@@ -105,9 +105,9 @@
<!-- Trigger Scan Modal --> <!-- Trigger Scan Modal -->
<div class="modal fade" id="triggerScanModal" tabindex="-1"> <div class="modal fade" id="triggerScanModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;"> <div class="modal-content">
<div class="modal-header" style="border-bottom: 1px solid #334155;"> <div class="modal-header">
<h5 class="modal-title" style="color: #60a5fa;">Trigger New Scan</h5> <h5 class="modal-title">Trigger New Scan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -132,7 +132,7 @@
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div> <div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
</form> </form>
</div> </div>
<div class="modal-footer" style="border-top: 1px solid #334155;"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()"> <button type="button" class="btn btn-primary" id="trigger-scan-btn" onclick="triggerScan()">
<span id="modal-trigger-text">Trigger Scan</span> <span id="modal-trigger-text">Trigger Scan</span>
@@ -510,24 +510,5 @@
} }
} }
// 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> </script>
{% endblock %} {% endblock %}

View File

@@ -419,20 +419,16 @@ document.getElementById('create-schedule-form').addEventListener('submit', async
// Show notification // Show notification
function showNotification(message, type = 'info') { function showNotification(message, type = 'info') {
const container = document.getElementById('notification-container');
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`; notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.innerHTML = ` notification.innerHTML = `
${message} ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`; `;
document.body.appendChild(notification); container.appendChild(notification);
setTimeout(() => { setTimeout(() => {
notification.remove(); notification.remove();

View File

@@ -554,20 +554,16 @@ async function deleteSchedule() {
// Show notification // Show notification
function showNotification(message, type = 'info') { function showNotification(message, type = 'info') {
const container = document.getElementById('notification-container');
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`; notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.innerHTML = ` notification.innerHTML = `
${message} ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`; `;
document.body.appendChild(notification); container.appendChild(notification);
setTimeout(() => { setTimeout(() => {
notification.remove(); notification.remove();

View File

@@ -6,7 +6,7 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Scheduled Scans</h1> <h1>Scheduled Scans</h1>
<a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary"> <a href="{{ url_for('main.create_schedule') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule <i class="bi bi-plus-circle"></i> New Schedule
</a> </a>
@@ -47,7 +47,7 @@
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0" style="color: #60a5fa;">All Schedules</h5> <h5 class="mb-0">All Schedules</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="schedules-loading" class="text-center py-5"> <div id="schedules-loading" class="text-center py-5">
@@ -352,21 +352,16 @@ async function deleteSchedule(scheduleId) {
// Show notification // Show notification
function showNotification(message, type = 'info') { function showNotification(message, type = 'info') {
// Create notification element const container = document.getElementById('notification-container');
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`; notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
notification.style.position = 'fixed';
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.style.minWidth = '300px';
notification.innerHTML = ` notification.innerHTML = `
${message} ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`; `;
document.body.appendChild(notification); container.appendChild(notification);
// Auto-remove after 5 seconds // Auto-remove after 5 seconds
setTimeout(() => { setTimeout(() => {

View File

@@ -1,95 +1,60 @@
<!DOCTYPE html> {% extends "base.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"> {% block title %}Setup - SneakyScanner{% endblock %}
<strong>Welcome!</strong> Please set an application password to secure your scanner.
</div>
{% with messages = get_flashed_messages(with_categories=true) %} {% set hide_nav = 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') }}"> {% block content %}
<div class="mb-3"> <div class="login-card">
<label for="password" class="form-label">Password</label> <div class="text-center mb-4">
<input type="password" <h1 class="brand-title">SneakyScanner</h1>
class="form-control" <p class="brand-subtitle">Initial Setup</p>
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> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <div class="alert alert-info mb-4">
</body> <i class="bi bi-info-circle me-1"></i>
</html> <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 form-control-lg"
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 form-control-lg"
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>
{% endblock %}

View File

@@ -6,7 +6,7 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Site Management</h1> <h1>Site Management</h1>
<div> <div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createSiteModal"> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createSiteModal">
<i class="bi bi-plus-circle"></i> Create New Site <i class="bi bi-plus-circle"></i> Create New Site
@@ -44,7 +44,7 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0" style="color: #60a5fa;">All Sites</h5> <h5 class="mb-0">All Sites</h5>
<input type="text" id="search-input" class="form-control" style="max-width: 300px;" <input type="text" id="search-input" class="form-control" style="max-width: 300px;"
placeholder="Search sites..."> placeholder="Search sites...">
</div> </div>
@@ -93,31 +93,31 @@
<!-- Create Site Modal --> <!-- Create Site Modal -->
<div class="modal fade" id="createSiteModal" tabindex="-1"> <div class="modal fade" id="createSiteModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content" style="background-color: #1e293b; border: 1px solid #334155;"> <div class="modal-content">
<div class="modal-header" style="border-bottom: 1px solid #334155;"> <div class="modal-header">
<h5 class="modal-title" style="color: #60a5fa;"> <h5 class="modal-title">
<i class="bi bi-plus-circle"></i> Create New Site <i class="bi bi-plus-circle me-2"></i>Create New Site
</h5> </h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="create-site-form"> <form id="create-site-form">
<div class="mb-3"> <div class="mb-3">
<label for="site-name" class="form-label" style="color: #e2e8f0;">Site Name *</label> <label for="site-name" class="form-label">Site Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="site-name" required <input type="text" class="form-control" id="site-name" required
placeholder="e.g., Production Web Servers"> placeholder="e.g., Production Web Servers">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="site-description" class="form-label" style="color: #e2e8f0;">Description</label> <label for="site-description" class="form-label">Description</label>
<textarea class="form-control" id="site-description" rows="3" <textarea class="form-control" id="site-description" rows="3"
placeholder="Optional description of this site"></textarea> placeholder="Optional description of this site"></textarea>
</div> </div>
<div class="alert alert-info" style="background-color: #1e3a5f; border-color: #2d5a8c; color: #a5d6ff;"> <div class="alert alert-info">
<i class="bi bi-info-circle"></i> After creating the site, you'll be able to add IP addresses using CIDRs, individual IPs, or bulk import. <i class="bi bi-info-circle me-1"></i>After creating the site, you'll be able to add IP addresses using CIDRs, individual IPs, or bulk import.
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer" style="border-top: 1px solid #334155;"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="createSite()"> <button type="button" class="btn btn-primary" onclick="createSite()">
<i class="bi bi-check-circle"></i> Create Site <i class="bi bi-check-circle"></i> Create Site
@@ -1108,22 +1108,20 @@ async function saveIp() {
// Show alert // Show alert
function showAlert(type, message) { function showAlert(type, message) {
const alertHtml = ` const container = document.getElementById('notification-container');
<div class="alert alert-${type} alert-dismissible fade show mt-3" role="alert"> const notification = document.createElement('div');
${message} notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div> notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`; `;
const container = document.querySelector('.container-fluid'); container.appendChild(notification);
container.insertAdjacentHTML('afterbegin', alertHtml);
// Auto-dismiss after 5 seconds // Auto-dismiss after 5 seconds
setTimeout(() => { setTimeout(() => {
const alert = container.querySelector('.alert'); notification.remove();
if (alert) {
bootstrap.Alert.getInstance(alert)?.close();
}
}, 5000); }, 5000);
} }
@@ -1163,52 +1161,4 @@ document.getElementById('save-ip-btn').addEventListener('click', saveIp);
document.addEventListener('DOMContentLoaded', loadSites); document.addEventListener('DOMContentLoaded', loadSites);
</script> </script>
<style>
.stat-card {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border: 1px solid #475569;
border-radius: 10px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #60a5fa;
}
.stat-label {
color: #94a3b8;
font-size: 0.875rem;
margin-top: 5px;
}
.card {
background-color: #1e293b;
border: 1px solid #334155;
}
.card-header {
background-color: #334155;
border-bottom: 1px solid #475569;
}
.table {
color: #e2e8f0;
}
.table thead th {
color: #94a3b8;
border-bottom: 1px solid #475569;
}
.table tbody tr {
border-bottom: 1px solid #334155;
}
.table tbody tr:hover {
background-color: #334155;
}
</style>
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center mb-4"> <div class="col-12 d-flex justify-content-between align-items-center mb-4">
<h1 style="color: #60a5fa;">Webhook Management</h1> <h1>Webhook Management</h1>
<a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary"> <a href="{{ url_for('webhooks.new_webhook') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Webhook <i class="bi bi-plus-circle"></i> Add Webhook
</a> </a>

View File

@@ -1,91 +1,11 @@
""" """
Input validation utilities for SneakyScanner web application. Input validation utilities for SneakyScanner web application.
Provides validation functions for API inputs, file paths, and data integrity. Provides validation functions for API inputs and data integrity.
""" """
import os
from pathlib import Path
from typing import Optional from typing import Optional
import yaml
def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
"""
[DEPRECATED] Validate that a configuration file exists and is valid YAML.
This function is deprecated. Use config_id with database-stored configs instead.
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]]: def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
""" """
@@ -109,184 +29,3 @@ def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}' return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'
return True, None 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