nightly #3
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -1,44 +1,18 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en" data-bs-theme="dark">
|
|
||||||
<head>
|
{% block title %}Setup - SneakyScanner{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% set hide_nav = true %}
|
||||||
<title>Setup - SneakyScanner</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
{% block content %}
|
||||||
<style>
|
<div class="login-card">
|
||||||
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">
|
<div class="text-center mb-4">
|
||||||
<h1 class="brand-title">SneakyScanner</h1>
|
<h1 class="brand-title">SneakyScanner</h1>
|
||||||
<p class="text-muted">Initial Setup</p>
|
<p class="brand-subtitle">Initial Setup</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-info mb-4">
|
<div class="alert alert-info mb-4">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
<strong>Welcome!</strong> Please set an application password to secure your scanner.
|
<strong>Welcome!</strong> Please set an application password to secure your scanner.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,7 +31,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">Password</label>
|
<label for="password" class="form-label">Password</label>
|
||||||
<input type="password"
|
<input type="password"
|
||||||
class="form-control"
|
class="form-control form-control-lg"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
@@ -70,7 +44,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="confirm_password" class="form-label">Confirm Password</label>
|
<label for="confirm_password" class="form-label">Confirm Password</label>
|
||||||
<input type="password"
|
<input type="password"
|
||||||
class="form-control"
|
class="form-control form-control-lg"
|
||||||
id="confirm_password"
|
id="confirm_password"
|
||||||
name="confirm_password"
|
name="confirm_password"
|
||||||
required
|
required
|
||||||
@@ -83,13 +57,4 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endblock %}
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show mb-2`;
|
||||||
|
|
||||||
|
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>
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user